diff --git a/cecli/__init__.py b/cecli/__init__.py index 6fe485a8330..866488fac48 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "1.3.2.dev" +__version__ = "1.5.0.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index 43d7e8b1e92..063f482e59b 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -578,6 +578,18 @@ def get_parser(default_config_files, git_root): help="Number of times to ping at 5min intervals to keep prompt cache warm (default: 0)", ) + ########## + group = parser.add_argument_group("Rate limiting") + group.add_argument( + "--tokens-per-minute", + type=int, + default=1000000, + help=( + "Set the maximum tokens sent per minute before rate limiting sleeps are" + " inserted before LLM API calls (default: 1000000, use 0 to disable)" + ), + ) + ########## group = parser.add_argument_group("Repomap settings") group.add_argument( @@ -1062,8 +1074,11 @@ def get_parser(default_config_files, git_root): group.add_argument( "--voice-language", metavar="VOICE_LANGUAGE", - default="en", - help="Specify the language for voice using ISO 639-1 code (default: auto)", + default=None, + help=( + "Specify the language for voice using ISO 639-1 code " + "(default: resolve from the voice setting, then chat language)" + ), ) group.add_argument( "--voice-input-device", diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index bf52fff2d51..d31258bf0aa 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -74,6 +74,9 @@ GLOBAL_DATE = date.today().isoformat() +# Default per-minute token budget used for rate limiting when not configured. +DEFAULT_TOKENS_PER_MINUTE = 1000000 + class UnknownEditFormat(ValueError): def __init__(self, edit_format, valid_formats): @@ -119,6 +122,8 @@ class UsageMeta(type): _total_tokens_sent = 0 _total_tokens_received = 0 _total_cached_tokens = 0 + _token_usage_buffer = {} + _token_usage_window = 60.0 @property def total_cost(cls): @@ -152,6 +157,47 @@ def total_cached_tokens(cls): def total_cached_tokens(cls, value): UsageMeta._total_cached_tokens = value + @classmethod + def _purge_token_usage(cls, model, now=None): + """Drop a model's token-usage entries older than the rolling window.""" + if now is None: + now = time.time() + cutoff = now - UsageMeta._token_usage_window + entries = UsageMeta._token_usage_buffer.get(model, []) + UsageMeta._token_usage_buffer[model] = [ + (tokens, ts) for tokens, ts in entries if ts >= cutoff + ] + + @classmethod + def _record_token_usage(cls, model, delta, now=None): + """Record a model's request prompt-token usage as (tokens, timestamp).""" + if delta <= 0 or model is None: + return + if now is None: + now = time.time() + UsageMeta._token_usage_buffer.setdefault(model, []).append((delta, now)) + UsageMeta._purge_token_usage(model, now) + + @classmethod + def _get_token_usage_stats(cls, model, now=None): + """Return a model's (tokens_last_minute, max_single_request, requests_per_minute).""" + if now is None: + now = time.time() + UsageMeta._purge_token_usage(model, now) + buffer = UsageMeta._token_usage_buffer.get(model, []) + tokens_last_minute = sum(tokens for tokens, _ in buffer) + max_single_request = max((tokens for tokens, _ in buffer), default=0) + requests_per_minute = len(buffer) + return tokens_last_minute, max_single_request, requests_per_minute + + @classmethod + def _reset_token_usage(cls, model=None): + """Clear the rolling token-usage buffer for one model, or all when model is None.""" + if model is None: + UsageMeta._token_usage_buffer = {} + else: + UsageMeta._token_usage_buffer.pop(model, None) + class Coder(metaclass=UsageMeta): @@ -188,6 +234,10 @@ def total_cached_tokens(self): def total_cached_tokens(self, value): type(self).total_cached_tokens = value + def _reset_token_usage(self): + """Clear rolling token usage after restoring a saved session.""" + UsageMeta._reset_token_usage() + abs_fnames = None abs_read_only_fnames = None abs_read_only_stubs_fnames = None @@ -315,7 +365,7 @@ async def create( read_only_stubs_fnames=list( from_coder.abs_read_only_stubs_fnames ), # Copy read-only stubs - rules_fnames=list(from_coder.abs_rules_fnames), # Copy read-only stubs + rules_fnames=list(from_coder.abs_rules_fnames), # Copy rules files done_messages=[], cur_messages=[], coder_commit_hashes=from_coder.coder_commit_hashes, @@ -383,6 +433,10 @@ async def create( if local_server and local_server.is_connected: await res.mcp_manager.disconnect_server("Local") + if res.uuid == from_coder.uuid: + res.prompt_queue = from_coder.prompt_queue.copy() + res._queue_counter = from_coder._queue_counter + await res.initialize_mcp_tools() # Store only small/primitive kwargs to avoid retaining large object references. @@ -2293,6 +2347,7 @@ async def summarize_and_update(messages, tag): asyncio.create_task(invoke_memorizer(self, additional_context=text)) + await self._rate_limit_sleep() if done_tokens > self.context_compaction_max_tokens or done_tokens > cur_tokens: await summarize_and_update(done_messages, MessageTag.DONE) @@ -2618,7 +2673,10 @@ async def check_tokens(self, messages): max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0 if max_input_tokens and input_tokens >= max_input_tokens: - if self.enable_context_compaction: + if ( + self.enable_context_compaction + and input_tokens >= self.context_compaction_max_tokens * 0.95 + ): self.io.tool_output( f"Estimated chat context of {input_tokens:,} tokens exceeds the" f" {max_input_tokens:,} token limit. Attempting to compact..." @@ -2630,21 +2688,29 @@ async def check_tokens(self, messages): input_tokens = self.get_active_model().token_count(messages) if max_input_tokens and input_tokens >= max_input_tokens: - self.io.tool_error( - f"Your estimated chat context of {input_tokens:,} tokens still exceeds the" - f" {max_input_tokens:,} token limit for {self.get_active_model().name}!" - ) - self.io.tool_output("To reduce the chat context:") - self.io.tool_output("- Use /drop to remove unneeded files from the chat") - self.io.tool_output("- Use /clear to clear the chat history") - self.io.tool_output("- Break your code into smaller files") - self.io.tool_output( - "It's probably safe to try and send the request, most providers won't charge if" - " the context limit is exceeded." - ) + if not hasattr(self, "_last_compaction_warning_time"): + self._last_compaction_warning_time = time.time() - if not await self.io.confirm_ask("Try to proceed anyway?"): - return None + if getattr(self, "_last_compaction_warning_time", 0) + 300 < time.time(): + self._last_compaction_warning_time = time.time() + self.io.tool_error( + f"Your estimated chat context of {input_tokens:,} tokens still exceeds the" + f" {max_input_tokens:,} token limit for {self.get_active_model().name}!" + ) + self.io.tool_output("To reduce the chat context:") + self.io.tool_output("- Use /drop to remove unneeded files from the chat") + self.io.tool_output("- Use /clear to clear the chat history") + self.io.tool_output("- Break your code into smaller files") + self.io.tool_output( + "It's probably safe to try and send the request, most providers won't charge if" + " the context limit is exceeded." + ) + + if not await self.io.confirm_ask("Try to proceed anyway?"): + self._last_compaction_warning_time = 0 + return None + else: + self._last_compaction_warning_time = 0 return messages @@ -3756,6 +3822,7 @@ async def send(self, messages, model=None, functions=None, tools=None): while True: try: + await self._rate_limit_sleep(model) completion_coro = model.send_completion( messages, functions, @@ -3764,6 +3831,7 @@ async def send(self, messages, model=None, functions=None, tools=None): tools=tools, override_kwargs=self.model_kwargs.copy(), interrupt_event=self.interrupt_event, + uuid=self.uuid, ) try: @@ -3830,7 +3898,7 @@ async def send(self, messages, model=None, functions=None, tools=None): if response: completion = response # Calculate costs for successful responses - self.calculate_and_show_tokens_and_cost(messages, completion) + self.calculate_and_show_tokens_and_cost(messages, completion, model=model) except litellm_ex.exceptions_tuple() as err: self.error_code = 1 @@ -3838,7 +3906,7 @@ async def send(self, messages, model=None, functions=None, tools=None): if ex_info.name == "ContextWindowExceededError": # Still calculate costs for context window errors self.token_profiler.on_error() - self.calculate_and_show_tokens_and_cost(messages, completion) + self.calculate_and_show_tokens_and_cost(messages, completion, model=model) raise except (KeyboardInterrupt, asyncio.CancelledError) as kbi: self.error_code = 130 # apparently standard? @@ -4431,56 +4499,52 @@ def remove_reasoning_content(self): self.reasoning_tag_name, ) - def calculate_and_show_tokens_and_cost(self, messages, completion=None): + def calculate_and_show_tokens_and_cost(self, messages, completion=None, model=None): + active_model = model or self.get_active_model() prompt_tokens = 0 completion_tokens = 0 cache_hit_tokens = 0 cache_write_tokens = 0 + usage = nested.getter(completion, "usage") if completion else None if ( - completion - and nested.getter(completion, "usage.prompt_tokens") is not None - and nested.getter(completion, "usage.completion_tokens") is not None + usage is not None + and nested.getter(usage, ["prompt_tokens", "input_tokens"]) is not None + and nested.getter(usage, ["completion_tokens", "output_tokens"]) is not None ): prompt_tokens = ( - nested.getter(completion.usage, "prompt_tokens", 0) - or nested.getter(completion.usage, "prompt_eval_count", 0) - or 0 + nested.getter(usage, ["prompt_tokens", "input_tokens", "prompt_eval_count"], 0) or 0 ) completion_tokens = ( - nested.getter(completion.usage, "completion_tokens", 0) - or nested.getter(completion.usage, "eval_count", 0) - or 0 + nested.getter(usage, ["completion_tokens", "output_tokens", "eval_count"], 0) or 0 ) - cache_hit_tokens = ( - getattr(completion.usage, "prompt_cache_hit_tokens", 0) - or getattr(completion.usage, "cache_read_input_tokens", 0) - or nested.getter(completion.usage, "prompt_tokens_details.cached_tokens", 0) - or 0 + cache_hit_tokens = _first_usage_tokens( + usage, + [ + "prompt_cache_hit_tokens", + "cache_read_input_tokens", + "input_tokens_details.cached_tokens", + "prompt_tokens_details.cached_tokens", + ], + 0, ) - cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0) or 0 + cache_write_tokens = nested.getter(usage, "cache_creation_input_tokens", 0) or 0 self.message_cached_tokens += cache_hit_tokens - - # ``prompt_tokens`` is normalized to the full input (including any - # cache read/write) for anthropic usage, so no separate add here. self.message_tokens_sent += prompt_tokens - else: - prompt_tokens = self.get_active_model().token_count(messages) - completion_tokens = self.get_active_model().token_count(self.partial_response_content) + prompt_tokens = active_model.token_count(messages) + completion_tokens = active_model.token_count(self.partial_response_content) self.message_tokens_sent += prompt_tokens self.message_tokens_received += completion_tokens + UsageMeta._record_token_usage(active_model.name, prompt_tokens) - # Build tokens string as "{prompt} CH {hit_rate:.1f}% ↑ {completion} ↓" if prompt_tokens > 0: hit_rate = round(cache_hit_tokens / prompt_tokens * 100, 1) if cache_hit_tokens else 0.0 else: hit_rate = 0.0 tokens_str = f"{format_tokens(prompt_tokens)} ◇ {hit_rate:.1f}%" - tokens_report = f"{tokens_str} ↑ {format_tokens(completion_tokens)} ↓" - tokens_report = self.token_profiler.add_to_usage_report( tokens_report, self.message_tokens_sent, self.message_tokens_received ) @@ -4503,36 +4567,31 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None): total_hit_rate = 0.0 total_stats = f"{format_tokens(total_combined_tokens)} ◇ {total_hit_rate:.1f}% ↑↓" - - if not self.get_active_model().info.get("input_cost_per_token"): + if not active_model.info.get("input_cost_per_token"): self.usage_report = tokens_report + " " + total_stats return try: - # Try and use litellm's built in cost calculator. Seems to work for non-streaming only? cost = litellm.completion_cost(completion_response=completion) except Exception: cost = 0 if not cost: cost = self.compute_costs_from_tokens( - prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens + prompt_tokens, + completion_tokens, + cache_write_tokens, + cache_hit_tokens, + model=active_model, ) self.total_cost += cost self.message_cost += cost - cost_report = ( f"${self.format_cost(self.message_cost)} • {total_stats}" f" ${self.format_cost(self.total_cost)}" ) - - if cache_hit_tokens and cache_write_tokens: - sep = " " - else: - sep = " " - - self.usage_report = tokens_report + sep + cost_report + self.usage_report = tokens_report + " " + cost_report def format_cost(self, value): if value == 0: @@ -4544,31 +4603,24 @@ def format_cost(self, value): return f"{value:.{max(2, 2 - int(math.log10(magnitude)))}f}" def compute_costs_from_tokens( - self, prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens + self, prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens, model=None ): cost = 0 + active_model = model or self.get_active_model() + info = getattr(active_model, "info", {}) or {} - input_cost_per_token = self.get_active_model().info.get("input_cost_per_token") or 0 - output_cost_per_token = self.get_active_model().info.get("output_cost_per_token") or 0 + input_cost_per_token = info.get("input_cost_per_token") or 0 + output_cost_per_token = info.get("output_cost_per_token") or 0 input_cost_per_token_cache_hit = ( - self.get_active_model().info.get("input_cost_per_token_cache_hit") - or self.get_active_model().info.get("cache_read_input_token_cost") + info.get("input_cost_per_token_cache_hit") + or info.get("cache_read_input_token_cost") or 0 ) - # deepseek - # prompt_cache_hit_tokens + prompt_cache_miss_tokens - # == prompt_tokens == total tokens that were sent - # - # Anthropic - # cache_creation_input_tokens + cache_read_input_tokens + prompt - # == total tokens that were - if input_cost_per_token_cache_hit: cost += cache_hit_tokens * input_cost_per_token_cache_hit cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token else: - # hard code the anthropic adjustments, no-ops for other models since cache_x_tokens==0 cost += cache_write_tokens * input_cost_per_token * 1.25 cost += cache_hit_tokens * input_cost_per_token * 0.10 cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token @@ -4576,6 +4628,141 @@ def compute_costs_from_tokens( cost += completion_tokens * output_cost_per_token return cost + def record_background_usage_and_cost(self, messages, completion=None, model=None): + """Account for token usage and cost of a background model call.""" + active_model = model or self.get_active_model() + prompt_tokens = 0 + completion_tokens = 0 + cache_hit_tokens = 0 + cache_write_tokens = 0 + usage = nested.getter(completion, "usage") if completion else None + + if usage is not None: + prompt_tokens = ( + nested.getter(usage, ["prompt_tokens", "input_tokens", "prompt_eval_count"], 0) or 0 + ) + completion_tokens = ( + nested.getter(usage, ["completion_tokens", "output_tokens", "eval_count"], 0) or 0 + ) + cache_hit_tokens = _first_usage_tokens( + usage, + [ + "prompt_cache_hit_tokens", + "cache_read_input_tokens", + "input_tokens_details.cached_tokens", + "prompt_tokens_details.cached_tokens", + ], + 0, + ) + cache_write_tokens = nested.getter(usage, "cache_creation_input_tokens", 0) or 0 + elif active_model is not None: + prompt_tokens = active_model.token_count(messages) or 0 + + model_name = getattr(active_model, "name", None) + UsageMeta._record_token_usage(model_name, prompt_tokens) + self.total_tokens_sent += prompt_tokens + self.total_tokens_received += completion_tokens + self.total_cached_tokens += cache_hit_tokens + + info = getattr(active_model, "info", {}) or {} + if not info.get("input_cost_per_token"): + return + + try: + cost = litellm.completion_cost(completion_response=completion) + except Exception: + cost = 0 + + if not cost: + cost = self.compute_costs_from_tokens( + prompt_tokens, + completion_tokens, + cache_write_tokens, + cache_hit_tokens, + model=active_model, + ) + + self.total_cost += cost + + def calculate_dynamic_sleep(self, model=None): + """Compute how long to sleep before the next LLM API call to stay under the configured per-minute token limit. + + Uses the rolling token-usage buffer (last 60s) to estimate: + + * ``used_last_min`` - prompt tokens already consumed in the window + * ``max_request`` - largest single request observed in the window + * ``requests_per_min`` - request rate observed in the window + + Returns a sleep duration (seconds) that is ``0`` when the current + trajectory stays within 90% of ``tokens_per_minute`` both over the next + 15 seconds and, if sustained, over a full minute. Otherwise it returns a + pause rounded up to the nearest 0.25s interval. + """ + limit = nested.getter( + getattr(self, "args", None), "tokens_per_minute", DEFAULT_TOKENS_PER_MINUTE + ) + if not isinstance(limit, (int, float)): + limit = DEFAULT_TOKENS_PER_MINUTE + if limit <= 0: + return 0.0 + + active_model = model or getattr(self, "main_model", None) + if active_model is None: + get_active_model = getattr(self, "get_active_model", None) + if callable(get_active_model): + active_model = get_active_model() + + model_name = getattr(active_model, "name", None) + if not model_name: + return 0.0 + + budget = limit * 0.9 + used_last_min, max_request, requests_per_min = UsageMeta._get_token_usage_stats(model_name) + + if max_request <= 0 or requests_per_min <= 0: + # No recent usage to throttle. + return 0.0 + + # Tokens we would consume in the next 15 seconds at the current rate. + projected_15s = max_request * (requests_per_min / 60.0) * 15.0 + + # If the current trajectory stays within budget (both over the next 15s + # and if sustained for a full minute) there is nothing to do. + if (used_last_min + projected_15s) <= budget and (max_request * requests_per_min) <= budget: + return 0.0 + + # Slow the request rate so a sustained minute at max_request size fits + # the budget. + sustainable_rpm = budget / max_request + sustainable_interval = 60.0 / sustainable_rpm if sustainable_rpm > 0 else 60.0 + current_interval = 60.0 / requests_per_min + sleep = max(0.0, sustainable_interval - current_interval) + + # If the next 15 seconds (plus what we've already used) would breach the + # budget, wait for the rolling window to drain enough to absorb it. + overshoot = (used_last_min + projected_15s) - budget + if overshoot > 0: + drain = 60.0 * (overshoot / max(used_last_min, 1.0)) + sleep = max(sleep, drain) + + # Cap at one full window; round up to the nearest 0.25s. + sleep = min(sleep, UsageMeta._token_usage_window) + return math.ceil(sleep / 0.25) * 0.25 + + async def _rate_limit_sleep(self, model=None): + """Sleep (if needed) before an LLM API call to respect the per-minute token limit. + + Best-effort: an interrupt simply skips the pause and is handled at the + next interruptible API point. + """ + delay = self.calculate_dynamic_sleep(model=model) + if delay <= 0: + return + + _, interrupted = await coroutines.interruptible(asyncio.sleep(delay), self.interrupt_event) + if interrupted: + return + def show_usage_report(self): if not self.usage_report: return @@ -5164,8 +5351,21 @@ def _function_call_to_dict(function_call): """Normalize a function call (dict or litellm-shaped Function) to a dict.""" if isinstance(function_call, dict): return function_call - if hasattr(function_call, "to_dict"): return function_call.to_dict() - return function_call + + +def _first_usage_tokens(usage: object, paths: list[str], default: int = 0) -> int: + """Return the first non-None token count among ``paths``. + + ``nested.getter`` stops at the first attribute that exists even when its value + is None. Anthropic/copilot ``Usage`` always declares ``prompt_cache_hit_tokens`` + (None) and populates ``cache_read_input_tokens``, so looking only at the first + field would report zero cache hits despite the server serving the cached prefix. + """ + for path in paths: + value = nested.getter(usage, path, None) + if value is not None: + return value + return default diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 0905610dc01..f081e306672 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -37,7 +37,9 @@ from .history_search import HistorySearchCommand from .hooks import HooksCommand from .hot_reload import HotReloadCommand +from .import_skill import ImportSkillCommand from .include_skill import IncludeSkillCommand +from .insert_queue import InsertQueueCommand from .lint import LintCommand from .list_mcp import ListMcpCommand from .list_queue import ListQueueCommand @@ -130,10 +132,9 @@ CommandRegistry.register(HistorySearchCommand) CommandRegistry.register(HooksCommand) CommandRegistry.register(HotReloadCommand) -CommandRegistry.register(ReapAgentCommand) -CommandRegistry.register(SpawnAgentCommand) -CommandRegistry.register(SwitchAgentCommand) +CommandRegistry.register(ImportSkillCommand) CommandRegistry.register(IncludeSkillCommand) +CommandRegistry.register(InsertQueueCommand) CommandRegistry.register(LintCommand) CommandRegistry.register(ListMcpCommand) CommandRegistry.register(ListQueueCommand) @@ -156,6 +157,7 @@ CommandRegistry.register(QuitCommand) CommandRegistry.register(ReadOnlyCommand) CommandRegistry.register(ReadOnlyStubCommand) +CommandRegistry.register(ReapAgentCommand) CommandRegistry.register(ReasoningEffortCommand) CommandRegistry.register(RemoveHookCommand) CommandRegistry.register(RemoveMcpCommand) @@ -170,6 +172,8 @@ CommandRegistry.register(SaveSessionCommand) CommandRegistry.register(SearchMemoryCommand) CommandRegistry.register(SettingsCommand) +CommandRegistry.register(SpawnAgentCommand) +CommandRegistry.register(SwitchAgentCommand) CommandRegistry.register(TerminalSetupCommand) CommandRegistry.register(TestCommand) CommandRegistry.register(ThinkTokensCommand) @@ -220,17 +224,17 @@ "HistorySearchCommand", "HooksCommand", "HotReloadCommand", + "ImportSkillCommand", "IncludeSkillCommand", - "ReapAgentCommand", - "SpawnAgentCommand", - "SwitchAgentCommand", + "InsertQueueCommand", "LintCommand", + "ListMcpCommand", + "ListQueueCommand", "ListSessionsCommand", "ListSkillsCommand", "LoadCommand", "LoadHookCommand", "LoadMcpCommand", - "ListMcpCommand", "LoadSessionCommand", "LoadSkillCommand", "LsCommand", @@ -242,11 +246,12 @@ "MultilineModeCommand", "parse_quoted_filenames", "PasteCommand", - "quote_filename", "QueueCommand", "QuitCommand", + "quote_filename", "ReadOnlyCommand", "ReadOnlyStubCommand", + "ReapAgentCommand", "ReasoningEffortCommand", "ReloadProgramSignal", "RemoveHookCommand", @@ -262,6 +267,8 @@ "SaveSessionCommand", "SearchMemoryCommand", "SettingsCommand", + "SpawnAgentCommand", + "SwitchAgentCommand", "SwitchCoderSignal", "TerminalSetupCommand", "TestCommand", diff --git a/cecli/commands/core.py b/cecli/commands/core.py index c8df65069af..2977ad172ed 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -107,6 +107,12 @@ def prompt_queue(self): coder = self.coder return coder.prompt_queue if coder is not None else [] + def _insert_prompt(self, text: str, index: int) -> dict: + """Insert a prompt at the given index in the active coder's queue.""" + from cecli.helpers import command_queue + + return command_queue.insert_prompt(self._active_coder(), text, index) + def _enqueue_prompt(self, text: str) -> dict: """Add a prompt to the owning coder's queue.""" from cecli.helpers import command_queue diff --git a/cecli/commands/import_skill.py b/cecli/commands/import_skill.py new file mode 100644 index 00000000000..93bfdcacd04 --- /dev/null +++ b/cecli/commands/import_skill.py @@ -0,0 +1,95 @@ +from typing import List + +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + + +class ImportSkillCommand(BaseCommand): + NORM_NAME = "import-skill" + DESCRIPTION = "Import a skill from the community registry or skills.sh (agent mode only)" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the import-skill command with given parameters.""" + tokens = args.strip().split() + global_install = "--global" in tokens or "-g" in tokens + tokens = [token for token in tokens if token not in ("--global", "-g")] + + if not tokens: + io.tool_output("Usage: /import-skill [--global] ") + return format_command_result( + io, "import-skill", "Usage: /import-skill [--global] " + ) + + skill_name = " ".join(tokens) + + # Importing (and including) skills is only available in agent mode. + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill import is only available in agent mode.") + return format_command_result( + io, "import-skill", "Skill import is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + return format_command_result(io, "import-skill", "Skills manager is not initialized") + + from cecli.helpers.extensions.skills_importer import ( + add_skill_to_config, + install_skill, + ) + + root = getattr(coder, "primary_root", None) or getattr(coder, "root", None) + result = install_skill(skill_name, global_install=global_install, root=root) + + if not result["ok"]: + io.tool_output(result["message"]) + return format_command_result(io, "import-skill", result["message"]) + + imported_name = result["name"] + include_result = coder.skills_manager.include_skill(imported_name) + config_result = add_skill_to_config(imported_name, root=root) + + message = ( + f"Imported skill '{imported_name}' from {result['source']} to {result['dest']}.\n\n" + f"{include_result}\n\n" + f"{config_result}" + ) + + return format_command_result(io, "import-skill", message) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for import-skill command.""" + candidates = ["--global"] + + try: + from cecli.helpers.extensions.skills_importer import get_registry_skills + + candidates.extend(get_registry_skills()) + except Exception: + pass + + return candidates + + @classmethod + def get_help(cls) -> str: + """Get help text for the import-skill command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += ( + " /import-skill # Import a skill into the project .cecli/skills\n" + ) + help_text += ( + " /import-skill --global # Import a skill into ~/.cecli/skills\n" + ) + help_text += "\nExamples:\n" + help_text += ( + " /import-skill files/docx # Import the docx skill from the community registry\n" + ) + help_text += " /import-skill --global pdf # Import the PDF skill globally\n" + help_text += ( + "\nSkills are looked up in the cecli community registry first, then on skills.sh.\n" + ) + help_text += "The imported skill is added to the current session like /include-skill.\n" + return help_text diff --git a/cecli/commands/insert_queue.py b/cecli/commands/insert_queue.py new file mode 100644 index 00000000000..fec5b0cda17 --- /dev/null +++ b/cecli/commands/insert_queue.py @@ -0,0 +1,86 @@ +"""Insert-queue command for CLI-33: inserts a prompt at a specific queue position.""" + +from typing import List + +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + + +class InsertQueueCommand(BaseCommand): + NORM_NAME = "insert-queue" + DESCRIPTION = "Insert a prompt into the queue at a specific position" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the insert-queue command with given parameters. + + Args: + io: InputOutput instance + coder: Coder instance (may be None for some commands) + args: Command arguments as string (" ") + **kwargs: Additional context + + Returns: + Formatted result string + """ + # Sad path: coder.commands is None + if not coder.commands: + return format_command_result( + io, + cls.NORM_NAME, + "", + error="Command system not available. Cannot insert into queue.", + ) + + # Sad path: missing index or prompt text + parts = (args or "").strip().split(maxsplit=1) + if len(parts) != 2: + return format_command_result( + io, + cls.NORM_NAME, + "", + error="Usage: /insert-queue ", + ) + + # Sad path: non-integer index + try: + index = int(parts[0]) + except ValueError: + return format_command_result( + io, + cls.NORM_NAME, + "", + error=f"Invalid index: '{parts[0]}'. Please provide a number.", + ) + + prompt_text = parts[1].strip() + + # Happy path: insert the prompt + try: + item = coder.commands._insert_prompt(prompt_text, index) + io.tool_output(f"Prompt inserted at position {index + 1} (id: {item['id']})") + return f"Successfully executed {cls.NORM_NAME}." + except ValueError as e: + return format_command_result(io, cls.NORM_NAME, "", error=str(e)) + except RuntimeError as e: + return format_command_result(io, cls.NORM_NAME, "", error=str(e)) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for insert-queue command.""" + return [] + + @classmethod + def get_help(cls) -> str: + """Get help text for the insert-queue command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /insert-queue # Insert at a specific position\n" + help_text += "\nDescription:\n" + help_text += " Inserts a prompt into the queue at the given 1-based position.\n" + help_text += " Existing items shift down. Index is clamped to the queue bounds.\n" + help_text += "\nExamples:\n" + help_text += " /insert-queue 1 Review the changes in src/main.py\n" + help_text += " /insert-queue 3 Write unit tests for the new feature\n" + help_text += "\nSee also: /queue, /list-queue, /remove-queue\n" + return help_text diff --git a/cecli/commands/list_queue.py b/cecli/commands/list_queue.py index 72305b09d8c..85ab6d9d1f8 100644 --- a/cecli/commands/list_queue.py +++ b/cecli/commands/list_queue.py @@ -27,7 +27,7 @@ async def execute(cls, io, coder, args, **kwargs): # Sad path: coder.commands is None if not coder.commands: return format_command_result( - io, cls.NORM_NAME, error="Command system not available. Cannot list queue." + io, cls.NORM_NAME, "", error="Command system not available. Cannot list queue." ) queue = coder.commands.prompt_queue diff --git a/cecli/commands/queue.py b/cecli/commands/queue.py index 7438ff42326..0d3113b4d97 100644 --- a/cecli/commands/queue.py +++ b/cecli/commands/queue.py @@ -26,7 +26,7 @@ async def execute(cls, io, coder, args, **kwargs): # Sad path: coder.commands is None if not coder.commands: return format_command_result( - io, cls.NORM_NAME, error="Command system not available. Cannot queue prompts." + io, cls.NORM_NAME, "", error="Command system not available. Cannot queue prompts." ) # Sad path: no args (empty prompt text) @@ -34,8 +34,11 @@ async def execute(cls, io, coder, args, **kwargs): return format_command_result( io, cls.NORM_NAME, - "Usage: /queue \n" - "Add a prompt to the queue for processing after current tasks complete.", + "", + error=( + "Usage: /queue - Add a prompt to the queue " + "for processing after current tasks complete." + ), ) prompt_text = args.strip() @@ -45,6 +48,7 @@ async def execute(cls, io, coder, args, **kwargs): return format_command_result( io, cls.NORM_NAME, + "", error=f"Prompt exceeds maximum length of 10000 characters " f"(got {len(prompt_text)}).", ) @@ -56,9 +60,9 @@ async def execute(cls, io, coder, args, **kwargs): io.tool_output(f"Prompt queued at position {position} (id: {item['id']})") return f"Successfully executed {cls.NORM_NAME}." except ValueError as e: - return format_command_result(io, cls.NORM_NAME, error=str(e)) + return format_command_result(io, cls.NORM_NAME, "", error=str(e)) except RuntimeError as e: - return format_command_result(io, cls.NORM_NAME, error=str(e)) + return format_command_result(io, cls.NORM_NAME, "", error=str(e)) @classmethod def get_completions(cls, io, coder, args) -> List[str]: diff --git a/cecli/commands/remove_queue.py b/cecli/commands/remove_queue.py index fc555869fe7..4ef3c9c6b68 100644 --- a/cecli/commands/remove_queue.py +++ b/cecli/commands/remove_queue.py @@ -26,13 +26,16 @@ async def execute(cls, io, coder, args, **kwargs): # Sad path: coder.commands is None if not coder.commands: return format_command_result( - io, cls.NORM_NAME, error="Command system not available. Cannot remove from queue." + io, + cls.NORM_NAME, + "", + error="Command system not available. Cannot remove from queue.", ) # Sad path: empty queue if coder.commands._get_queue_length() == 0: return format_command_result( - io, cls.NORM_NAME, error="Queue is empty. Nothing to remove." + io, cls.NORM_NAME, "", error="Queue is empty. Nothing to remove." ) # Handle wildcard: clear entire queue @@ -50,6 +53,7 @@ async def execute(cls, io, coder, args, **kwargs): return format_command_result( io, cls.NORM_NAME, + "", error=f"Invalid index: '{args.strip()}'. Please provide a number or '*'.", ) @@ -59,6 +63,7 @@ async def execute(cls, io, coder, args, **kwargs): return format_command_result( io, cls.NORM_NAME, + "", error=f"Index {args.strip()} is out of range. Queue has {queue_len} item(s).", ) diff --git a/cecli/commands/voice.py b/cecli/commands/voice.py index 6b40fddb538..11f4e2c4c65 100644 --- a/cecli/commands/voice.py +++ b/cecli/commands/voice.py @@ -1,10 +1,8 @@ -import os from typing import List import cecli.voice as voice from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.llm import litellm class VoiceCommand(BaseCommand): @@ -13,21 +11,38 @@ class VoiceCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): - """Execute the voice command with given parameters.""" - # Get voice parameters from kwargs or coder + """Record inline in the CLI, or toggle background recording in the TUI.""" + tui = coder.tui() if coder.tui else None + stop_queue = kwargs.get("stop_queue") + + if tui is not None and stop_queue is None: + tui.call_from_thread(tui.action_start_voice) + return "" + voice_language = kwargs.get("voice_language") or getattr(coder, "voice_language", None) voice_format = kwargs.get("voice_format") or getattr(coder, "voice_format", None) voice_input_device = kwargs.get("voice_input_device") or getattr( coder, "voice_input_device", None ) - # Get voice instance from kwargs or create new one + detected_language = None + get_user_language = getattr(coder, "get_user_language", None) + + if get_user_language: + detected_language = get_user_language() + + resolved_language = voice.resolve_moonshine_language(voice_language, detected_language) voice_instance = kwargs.get("voice_instance") + owns_voice = voice_instance is None - if not voice_instance: - if "OPENAI_API_KEY" not in os.environ: - io.tool_error("To use /voice you must provide an OpenAI API key.") - return format_command_result(io, "voice", "OpenAI API key required") + if owns_voice: + try: + import moonshine_voice # noqa: F401 + except ImportError: + io.tool_error( + "To use /voice you must install `moonshine-voice` (pip install moonshine-voice)." + ) + return format_command_result(io, "voice", "moonshine-voice not installed") try: voice_instance = voice.Voice( @@ -39,19 +54,51 @@ async def execute(cls, io, coder, args, **kwargs): ) return format_command_result(io, "voice", "Sound device error") + def on_text(partial): + if tui is not None: + tui.set_input_value(partial) + tui.refresh() + else: + io.placeholder = partial + + def on_status(message): + if tui is not None: + if "⬤ recording" in str(message) or "⬤ Transcribing" in str(message): + tui.set_voice_hint((message or "").strip()) + else: + io.tool_output((message or "").strip()) + else: + io.tool_output(message or "") + + stop_binding = tui.get_keys_for("voice") if tui is not None else None + try: - io.update_spinner("Recording...") - text = await voice_instance.record_and_transcribe(None, language=voice_language) - except litellm.OpenAIError as err: - io.tool_error(f"Unable to use OpenAI whisper model: {err}") - return format_command_result(io, "voice", f"OpenAI error: {err}") + if tui is not None: + tui.set_voice_hint("⬤ recording") + else: + io.update_spinner("⬤ recording") + text = await voice_instance.record_and_transcribe( + None, + language=resolved_language, + on_text=on_text, + on_status=on_status, + stop_binding=stop_binding, + stop_queue=stop_queue, + ) + except Exception as err: + io.tool_error(f"Unable to transcribe: {err}") + return format_command_result(io, "voice", f"Transcription error: {err}") + finally: + if owns_voice: + voice_instance.close() if text: io.placeholder = text - if coder.tui and coder.tui(): - coder.tui().set_input_value(text) - coder.tui().refresh() + if tui is not None: + tui.set_input_value(text) + tui.refresh() + return "" return format_command_result(io, "voice", "Voice recorded and transcribed") @@ -67,12 +114,17 @@ def get_help(cls) -> str: help_text += "\nUsage:\n" help_text += " /voice # Record and transcribe voice input\n" help_text += ( - "\nThis command records audio from your microphone and transcribes it using OpenAI's" - " Whisper model.\n" + "\nThis command records audio from your microphone and transcribes it on-device" + " using the Moonshine on-device model. The language is resolved from your" + " /voice-language setting, falling back to your chat language, then English.\n" + ) + help_text += ( + "\nIn the TUI, use the voice shortcut (Ctrl+R by default) to start and stop" + " background recording. /voice also toggles recording. Outside the TUI," + " press Enter to stop.\n" ) help_text += "Requirements:\n" - help_text += " - OPENAI_API_KEY environment variable must be set\n" + help_text += " - moonshine-voice, sounddevice, and soundfile Python packages\n" help_text += " - PortAudio library installed (for sounddevice)\n" - help_text += " - sounddevice and soundfile Python packages\n" help_text += "\nThe transcribed text will be placed in the input prompt for editing.\n" return help_text diff --git a/cecli/format_settings.py b/cecli/format_settings.py index 0ad54aa51aa..04d7c3ef056 100644 --- a/cecli/format_settings.py +++ b/cecli/format_settings.py @@ -1,3 +1,6 @@ +import os + + def scrub_sensitive_info(args, text): # Replace sensitive information with last 4 characters if text and args.openai_api_key: @@ -23,4 +26,13 @@ def format_settings(parser, args): if val: val = scrub_sensitive_info(args, str(val)) show += f" - {arg}: {val}\n" # noqa: E221 + # Add environment variables that start with CECLI_ + show += "\nEnvironment variables:\n" + for env_var, env_val in sorted(os.environ.items()): + if env_var.startswith("CECLI_"): + # Scrub sensitive env vars if needed + if "API_KEY" in env_var and env_val: + last_4 = env_val[-4:] if len(env_val) >= 4 else env_val + env_val = f"...{last_4}" + show += f" - {env_var}: {env_val}\n" return show diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index a7523d8145c..d1b224a7260 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -617,6 +617,10 @@ async def _create_sub_agent_coder( parent_uuid=parent_coder.uuid, map_tokens=0, init_metadata={"agent_config": agent_config}, + # Sub-agents start with an empty file context; rules are inherited. + fnames=[], + read_only_fnames=[], + read_only_stubs_fnames=[], ) if configured_root: kwargs["root"] = configured_root diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py index 988909c870f..a698ac1313c 100644 --- a/cecli/helpers/background_commands.py +++ b/cecli/helpers/background_commands.py @@ -43,14 +43,17 @@ def __init__(self, max_size: int = 4096): self.total_added = 0 # Track total characters added for new output detection def append(self, text: str) -> None: - """ - Add text to buffer, removing oldest content if exceeds max size. + """Append text, retaining only the newest ``max_size`` characters. - Args: - text: Text to append to buffer + Store individual characters so the deque limit measures characters, not + output chunks. Slice oversized inputs before extending to avoid processing + characters that would immediately be evicted. Track all received characters + in ``total_added`` so incremental read positions survive eviction. """ with self.lock: - self.buffer.append(text) + if self.max_size: + self.buffer.extend(text[-self.max_size :]) + self.total_added += len(text) def get_all(self, clear: bool = False) -> str: @@ -100,7 +103,7 @@ def clear(self) -> None: def size(self) -> int: """Get current buffer size in characters.""" with self.lock: - return sum(len(chunk) for chunk in self.buffer) + return len(self.buffer) class InputBuffer: diff --git a/cecli/helpers/command_queue.py b/cecli/helpers/command_queue.py index 5f1a0ff8f7c..1463cffc18c 100644 --- a/cecli/helpers/command_queue.py +++ b/cecli/helpers/command_queue.py @@ -61,6 +61,40 @@ def enqueue_prompt(coder, text: str) -> dict: return item +def insert_prompt(coder, text: str, index: int) -> dict: + """Insert a prompt at the given 0-based index and return the queued item. + + Args: + coder: The coder whose queue should be modified. + text: The prompt text to insert. + index: 0-based position to insert at. Clamped to [0, len(queue)]. + + Returns: + dict with keys: id (str), text (str), timestamp (float). + + Raises: + ValueError: If text is empty, None, or exceeds 10000 characters. + RuntimeError: If the queue is at max capacity (100 items). + """ + if not text or not text.strip(): + raise ValueError("Cannot enqueue empty prompt") + if len(text) > MAX_PROMPT_LENGTH: + raise ValueError("Prompt exceeds maximum length of 10000 characters") + if get_queue_length(coder) >= MAX_QUEUE_SIZE: + raise RuntimeError("Queue is full (max 100 items)") + + index = max(0, min(index, get_queue_length(coder))) + with _get_lock(coder): + coder._queue_counter += 1 + item = { + "id": str(coder._queue_counter), + "text": text, + "timestamp": time.time(), + } + coder.prompt_queue.insert(index, item) + return item + + def dequeue_prompt(coder) -> dict | None: """Remove and return the first item from the coder's queue (FIFO). diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index ae99c2b1019..202b00b7665 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -114,6 +114,10 @@ def add_system_messages(self) -> None: if self._cancel_post_message_injections(): return + # sub agents should not use default reminders + if coder.edit_format in ["subagent"]: + return + # Add system reminder as a pre-prompt context block use_reminders = getattr(coder.args, "use_reminders", True) if ( @@ -122,10 +126,7 @@ def add_system_messages(self) -> None: and coder.gpt_prompts.system_reminder ): msg = dict( - role="user", - content=self._shuffle_reminders( - coder.fmt_system_prompt(coder.gpt_prompts.system_reminder) - ), + role="user", content=coder.fmt_system_prompt(coder.gpt_prompts.system_reminder) ) ConversationService.get_manager(coder).add_message( message_dict=msg, @@ -155,21 +156,6 @@ def add_system_message(self, prompt: str) -> None: force=True, ) - msg = dict( - role="user", - content=self._shuffle_reminders( - coder.fmt_system_prompt(coder.gpt_prompts.system_reminder) - ), - ) - - ConversationService.get_manager(coder).add_message( - message_dict=msg, - tag=MessageTag.REMINDER, - hash_key=("main", "subagent_reminder"), - force=True, - mark_for_delete=0, - ) - def add_randomized_cta(self) -> None: coder = self.get_coder() if not coder: diff --git a/cecli/helpers/extensions/__init__.py b/cecli/helpers/extensions/__init__.py new file mode 100644 index 00000000000..5bee0516af2 --- /dev/null +++ b/cecli/helpers/extensions/__init__.py @@ -0,0 +1 @@ +"""Skill ecosystem extensions package.""" diff --git a/cecli/helpers/extensions/skills_importer.py b/cecli/helpers/extensions/skills_importer.py new file mode 100644 index 00000000000..5549c9f03f1 --- /dev/null +++ b/cecli/helpers/extensions/skills_importer.py @@ -0,0 +1,477 @@ +"""Import skills from the cecli community-resources registry and skills.sh. + +Skills are looked up in the community registry (``SKILLS_REGISTRY.json`` in the +``cecli-dev/community-resources`` repo) first, then on skills.sh. A skill is +resolved to a GitHub repo plus a folder path, downloaded from the repo tarball, +and installed into a local ``.cecli/skills`` directory or the global +``~/.cecli/skills`` directory. + +Skills resolved from skills.sh are gated on the public security-audit endpoint +(``/api/v1/skills/audit/...``) and are only auto-downloaded when every reported +audit passes. See ``skill_passes_security_audits``. +""" + +import io +import json +import re +import ssl +import tarfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import requests +import yaml + +REGISTRY_URL = ( + "https://raw.githubusercontent.com/cecli-dev/community-resources/main/SKILLS_REGISTRY.json" +) +REGISTRY_REPO = "cecli-dev/community-resources" +SKILLS_SH_SEARCH_URL = "https://www.skills.sh/api/search" +REGISTRY_CACHE_NAME = "skills_registry.json" +REGISTRY_CACHE_TTL = 60 * 60 * 24 # one day + +# Public skills.sh v1 API base. The audit endpoints under it need no auth. +SKILLS_SH_API_BASE = "https://www.skills.sh/api/v1/skills" + +# The classic third-party security audits every skills.sh skill must pass before +# we auto-download it. Slugs match the audit endpoint's ``audits[].slug``. +REQUIRED_SECURITY_AUDITS = frozenset({"agent-trust-hub", "socket", "snyk"}) + + +@dataclass +class SkillSource: + """A resolved skill source: a GitHub repo plus the skill folder path.""" + + repo: str + skill_id: str + name: str + source: str + + +def _cache_dir() -> Path: + return Path.home() / ".cecli" / "caches" + + +def _ssl_safe_get(url: str, **kwargs: Any) -> requests.Response: + """GET a URL, retrying once on the OpenSSL first-init flake. + + On some platforms (observed: WSL2 + OpenSSL 3.5 + Python 3.14) the very + first ``ssl.create_default_context(...)`` in a fresh process can fail with + ``ssl.SSLError`` (``[CONF: MODULE_INITIALIZATION_ERROR]`` / "unknown error + (0x0)") because the OpenSSL CONF module races its lazy initialization. A + second attempt succeeds. Retrying keeps outbound requests reliable. + Mirrors llms.runtime.make_client. + """ + + try: + return requests.get(url, **kwargs) + except (ssl.SSLError, requests.exceptions.SSLError): + return requests.get(url, **kwargs) + + +def get_registry_skills(force: bool = False) -> List[str]: + """Return the community-registry skill ids, cached for one day.""" + cache_file = _cache_dir() / REGISTRY_CACHE_NAME + + if ( + not force + and cache_file.exists() + and time.time() - cache_file.stat().st_mtime < REGISTRY_CACHE_TTL + ): + try: + data = json.loads(cache_file.read_text()) + if isinstance(data, list): + return data + except Exception: + pass + + try: + response = _ssl_safe_get(REGISTRY_URL, timeout=15) + response.raise_for_status() + data = response.json() + if isinstance(data, list): + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(data)) + return data + except Exception: + pass + + # Fall back to a stale cache when the network request fails. + if cache_file.exists(): + try: + data = json.loads(cache_file.read_text()) + if isinstance(data, list): + return data + except Exception: + pass + + return [] + + +def search_skills_sh(query: str, limit: int = 25) -> List[Dict[str, Any]]: + """Search skills.sh and return matching skills.""" + try: + response = _ssl_safe_get( + SKILLS_SH_SEARCH_URL, params={"q": query, "limit": limit}, timeout=15 + ) + response.raise_for_status() + data = response.json() + if isinstance(data, dict) and isinstance(data.get("skills"), list): + return data["skills"] + except Exception: + pass + + return [] + + +def _last_part(path: str) -> str: + return path.rstrip("/").rsplit("/", 1)[-1] + + +def _best_skill_match(matches: List[Dict[str, Any]], query: str) -> Optional[Dict[str, Any]]: + """Pick the best skills.sh result for a query.""" + + def score(item: Dict[str, Any]) -> int: + name = str(item.get("skillId") or item.get("name") or "").lower() + query_lower = query.lower() + + if name == query_lower: + result = 1000 + elif name.startswith(query_lower): + result = 500 + elif query_lower in name: + result = 200 + else: + result = 0 + + installs = item.get("installs", 0) + if isinstance(installs, (int, float)): + result += int(installs) + return result + + if not matches: + return None + + return sorted(matches, key=score, reverse=True)[0] + + +def resolve_skill(skill_name: str, force: bool = False) -> Optional[SkillSource]: + """Resolve a skill name to a source, checking the registry then skills.sh.""" + name = (skill_name or "").strip().strip("/") + if not name: + return None + + registry = get_registry_skills(force=force) + + if name in registry: + return SkillSource( + repo=REGISTRY_REPO, skill_id=name, name=_last_part(name), source="registry" + ) + + last = _last_part(name) + registry_matches = [rid for rid in registry if _last_part(rid) == last] + if len(registry_matches) == 1: + rid = registry_matches[0] + return SkillSource( + repo=REGISTRY_REPO, skill_id=rid, name=_last_part(rid), source="registry" + ) + + best = _best_skill_match(search_skills_sh(last), last) + if best is None: + return None + + skill_id = str(best.get("skillId") or last) + return SkillSource( + repo=best.get("source", ""), + skill_id=skill_id, + name=_last_part(skill_id), + source="skills.sh", + ) + + +def _parse_skill_name(raw: bytes) -> Optional[str]: + """Parse the ``name`` field from a SKILL.md frontmatter block.""" + try: + text = raw.decode("utf-8") + except Exception: + return None + + match = re.search(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL | re.MULTILINE) + if not match: + return None + + try: + frontmatter = yaml.safe_load(match.group(1)) + except Exception: + return None + + if isinstance(frontmatter, dict) and isinstance(frontmatter.get("name"), str): + return frontmatter["name"].strip().rstrip("/") + + return None + + +def _path_score(candidate: str, rel_skill_id: str) -> int: + """Score how closely a candidate folder matches the requested skill path.""" + if candidate == rel_skill_id: + return 0 + if candidate.endswith("/" + rel_skill_id): + return 1 + if candidate.endswith("/skills/" + rel_skill_id): + return 2 + return 3 + + +def _find_skill_dir(tf, members: List, prefix: str, skill_id: str) -> Optional[str]: + """Find the repo folder that contains the requested skill.""" + rel_skill_id = skill_id.strip("/") + last = _last_part(rel_skill_id) + target: Optional[str] = None + + for member in members: + if not member.path.endswith("/SKILL.md") or not member.isfile(): + continue + if not member.path.startswith(prefix + "/"): + continue + + rel = member.path[len(prefix) + 1 :] + skill_dir = rel[: -len("/SKILL.md")].rstrip("/") + if not skill_dir or Path(skill_dir).name != last: + continue + + raw = b"" + member_file = tf.extractfile(member) + if member_file is not None: + raw = member_file.read() + if _parse_skill_name(raw) != last: + continue + + if target is None or _path_score(skill_dir, rel_skill_id) < _path_score( + target, rel_skill_id + ): + target = skill_dir + + return target + + +def download_skill_folder(repo: str, skill_id: str, dest_dir: Path) -> Path: + """Download a skill folder from a GitHub repo into ``dest_dir``.""" + dest_dir.mkdir(parents=True, exist_ok=True) + url = f"https://api.github.com/repos/{repo}/tarball/main" + response = _ssl_safe_get(url, timeout=180, stream=True) + response.raise_for_status() + + tf = tarfile.open(fileobj=io.BytesIO(response.content), mode="r:gz") + members = tf.getmembers() + prefix = members[0].path.split("/", 1)[0] + + chosen = _find_skill_dir(tf, members, prefix, skill_id) + if chosen is None: + raise ValueError(f"Skill '{skill_id}' not found in '{repo}'") + + chosen_prefix = f"{prefix}/{chosen}" + for member in members: + if not member.path.startswith(chosen_prefix + "/"): + continue + + rel = member.path[len(chosen_prefix) + 1 :] + if not rel: + continue + + target = dest_dir / rel + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + target.parent.mkdir(parents=True, exist_ok=True) + with tf.extractfile(member) as src, open(target, "wb") as out: + out.write(src.read()) + + return dest_dir + + +def fetch_skill_audits(skill_path: str) -> Optional[Dict[str, Any]]: + """Return the security-audit payload for a skills.sh skill path. + + GETs the public ``/api/v1/skills/audit/{source}/{skill}`` endpoint, which + requires no auth. Returns ``None`` when the skill has never been audited + (404) or on any network/HTTP failure. + """ + url = f"{SKILLS_SH_API_BASE}/audit/{skill_path}" + try: + response = _ssl_safe_get(url, timeout=15) + except Exception: + return None + + if response.status_code != 200: + return None + + try: + data = response.json() + except Exception: + return None + + if isinstance(data, dict) and isinstance(data.get("audits"), list): + return data + + return None + + +def skill_passes_security_audits(skill_path: str) -> Tuple[bool, str]: + """Verdict on whether a skills.sh skill passes all security audits. + + A skill passes only when the audit endpoint reports every audit as ``pass`` + *and* the classic three providers (Gen Agent Trust Hub, Socket, Snyk) are all + present and passing. The check fails closed: missing audits, non-``pass`` + statuses, or a skill that has never been audited (404) all count as not + passing. + """ + data = fetch_skill_audits(skill_path) + if data is None: + return ( + False, + f"No security audit results found for '{skill_path}'; refusing to auto-download. " + "Audits are generated automatically after a skill is installed for the first time.", + ) + + audits = data.get("audits", []) + by_slug = {str(a.get("slug")): a for a in audits if isinstance(a, dict) and a.get("slug")} + + missing = sorted(REQUIRED_SECURITY_AUDITS - set(by_slug)) + if missing: + return ( + False, + f"Missing required security audit(s) ({', '.join(missing)}) for '{skill_path}'.", + ) + + for slug in REQUIRED_SECURITY_AUDITS: + entry = by_slug[slug] + if str(entry.get("status", "")).lower() != "pass": + return ( + False, + f"Security audit '{entry.get('provider', slug)}' did not pass for " + f"'{skill_path}' (status: {entry.get('status')}).", + ) + + # Any extra audit (e.g. Runlayer, ZeroLeaks) that is not passing also fails + # the "all audits pass" bar. + for entry in audits: + if isinstance(entry, dict) and str(entry.get("status", "")).lower() != "pass": + return ( + False, + f"Security audit '{entry.get('provider')}' did not pass for " + f"'{skill_path}' (status: {entry.get('status')}).", + ) + + return True, "All security audits pass." + + +def install_skill( + skill_name: str, global_install: bool = False, root: Optional[str] = None +) -> Dict[str, Any]: + """Install a skill into the local or global skills directory.""" + source = resolve_skill(skill_name) + if source is None: + return { + "ok": False, + "message": f"Skill '{skill_name}' not found in the community registry or on skills.sh.", + } + + if not source.repo or not source.name: + return {"ok": False, "message": f"Could not resolve a download source for '{skill_name}'."} + + if source.source == "skills.sh": + skill_path = f"{source.repo}/{source.skill_id}" + audits_ok, audits_msg = skill_passes_security_audits(skill_path) + if not audits_ok: + return {"ok": False, "message": audits_msg} + + if global_install: + base = Path.home() / ".cecli" / "skills" + else: + anchor = Path(root).expanduser().resolve() if root else Path.cwd() + base = anchor / ".cecli" / "skills" + + dest_dir = base / source.name + + try: + download_skill_folder(source.repo, source.skill_id, dest_dir) + except Exception as e: + return {"ok": False, "message": f"Failed to download skill '{source.name}': {e}"} + + return { + "ok": True, + "name": source.name, + "skill_id": source.skill_id, + "source": source.source, + "dest": str(dest_dir), + } + + +def _find_local_config(root: Optional[str] = None) -> Optional[Path]: + """Find the project ``.cecli.conf.yml`` config file.""" + start = Path(root).expanduser().resolve() if root else Path.cwd() + for parent in [start] + list(start.parents): + candidate = parent / ".cecli.conf.yml" + if candidate.exists(): + return candidate + + home_candidate = Path.home() / ".cecli.conf.yml" + return home_candidate if home_candidate.exists() else None + + +def _add_skill_to_config_file(config_path: Path, skill_name: str) -> bool: + """Add a skill to an existing non-empty ``skills_includelist``. Returns True if changed.""" + if not config_path.exists(): + return False + + try: + data = yaml.safe_load(config_path.read_text()) or {} + except Exception: + return False + + if not isinstance(data, dict): + return False + + agent = data.get("agent-config") + if isinstance(agent, str): + try: + agent = json.loads(agent) + except Exception: + return False + data["agent-config"] = agent + + if not isinstance(agent, dict): + return False + + include_list = agent.get("skills_includelist") + if not isinstance(include_list, list) or not include_list: + return False + + if skill_name in include_list: + return False + + include_list.append(skill_name) + try: + config_path.write_text(yaml.safe_dump(data, sort_keys=False)) + except Exception: + return False + + return True + + +def add_skill_to_config(skill_name: str, root: Optional[str] = None) -> str: + """Persist a skill name into a config include-list so it survives restarts.""" + local = _find_local_config(root) + global_cfg = Path.home() / ".cecli" / "conf.yml" + + if local is not None and _add_skill_to_config_file(local, skill_name): + return f"Added '{skill_name}' to the include list in {local}." + + if global_cfg.exists() and _add_skill_to_config_file(global_cfg, skill_name): + return f"Added '{skill_name}' to the include list in {global_cfg}." + + return ( + "No active skills include list found; the skill will be auto-discovered in future sessions." + ) diff --git a/cecli/helpers/llms/config.py b/cecli/helpers/llms/config.py index 27060b07fbd..6a9e1b7a44b 100644 --- a/cecli/helpers/llms/config.py +++ b/cecli/helpers/llms/config.py @@ -63,7 +63,7 @@ def resolve_model_config(model: str) -> Dict[str, Any]: # falls back to a bare (anthropic/openai) record (e.g. # ``github_copilot/claude-sonnet-4-5`` resolving to the bare # ``claude-sonnet-4-5`` anthropic entry). - if prefix in ("github_copilot", "bedrock", "bedrock_mantle"): + if prefix in ("github_copilot", "bedrock", "bedrock_mantle", "openrouter"): provider = prefix # Anthropic models hosted by a third-party provider (openrouter, deepseek, diff --git a/cecli/helpers/llms/domains/messages.py b/cecli/helpers/llms/domains/messages.py index a80561c5744..17e9d542235 100644 --- a/cecli/helpers/llms/domains/messages.py +++ b/cecli/helpers/llms/domains/messages.py @@ -315,7 +315,15 @@ async def anthropic_stream( ordered = [blocks[key] for key in sorted(blocks)] if ordered: - chunk.provider_specific_fields = {"anthropic": ordered} + provider_fields: Dict[str, Any] = {"anthropic": ordered} + + if any( + b.get("type") == "thinking" and (b.get("thinking") or "").strip() + for b in ordered + ): + provider_fields["use_thinking_summaries"] = True + + chunk.provider_specific_fields = provider_fields yield chunk @@ -379,6 +387,12 @@ def normalize_anthropic_response(data: Dict[str, Any], model: str) -> Completion provider_fields = {"anthropic": blocks} if blocks else {} + if any(b.get("type") == "thinking" and (b.get("thinking") or "").strip() for b in blocks): + # The assistant turn carries a readable thinking summary; mark it so the + # replay path can strip the summary text (keeping the signature) and keep + # the cached prefix stable across turns. + provider_fields["use_thinking_summaries"] = True + pm = PartsMessage(role="assistant", parts=parts, provider_metadata=provider_fields) message = parts_message_to_message(pm) @@ -554,7 +568,11 @@ def _anthropic_message_content(msg: Dict[str, Any]) -> Optional[List[Dict[str, A content.append({"type": "text", "text": block.get("text") or ""}) elif btype == "thinking": - entry: Dict[str, Any] = {"type": "thinking", "thinking": block.get("thinking") or ""} + # Summary text is display-only; strip it on replay (keeping the + # signature, which carries the encrypted chain-of-thought for + # continuity) so prior-turn reasoning doesn't churn the cached prefix. + thinking = "" if psf.get("use_thinking_summaries") else block.get("thinking") or "" + entry: Dict[str, Any] = {"type": "thinking", "thinking": thinking} if block.get("signature"): entry["signature"] = block["signature"] diff --git a/cecli/helpers/llms/domains/responses.py b/cecli/helpers/llms/domains/responses.py index 0566d8ea597..1858342e8db 100644 --- a/cecli/helpers/llms/domains/responses.py +++ b/cecli/helpers/llms/domains/responses.py @@ -58,13 +58,22 @@ def responses_payload( payload["tools"] = [responses_tool(t) for t in tools] api_block = resolved.get("api_block") or {} + + # Responses-mode models (gpt-5 / meta) are reasoning models: always opt in + # to a readable reasoning ``summary`` so the response (and the stream) expose + # the summary block the capture logic consumes. An ``effort`` rides along when + # the api_block configures one; without it the model uses its default effort. + reasoning_config: Dict[str, Any] = {"summary": "auto"} + if api_block.get("reasoning_effort"): - payload["reasoning"] = {"effort": api_block["reasoning_effort"], "summary": "auto"} + reasoning_config["effort"] = api_block["reasoning_effort"] # Encrypted reasoning blobs (meta muse-spark) are only returned when # explicitly requested; without them prior reasoning items cannot be # replayed on the next turn. payload["include"] = ["reasoning.encrypted_content"] + payload["reasoning"] = reasoning_config + if api_block.get("parallel_tool_calls") is not None: payload["parallel_tool_calls"] = api_block["parallel_tool_calls"] @@ -77,6 +86,11 @@ def responses_payload( payload["temperature"] = temperature extra_body = dict(kwargs.get("extra_body") or {}) + # Preserve the caller's cache key for providers that support Responses caching. + prompt_cache_key = kwargs.get("prompt_cache_key") + if prompt_cache_key: + payload["prompt_cache_key"] = prompt_cache_key + # The Responses API controls reasoning via the nested ``reasoning.effort`` # field (already handled above); a generic top-level ``thinking`` budget # (or flat ``reasoning_effort``) has no wire equivalent and would be @@ -85,6 +99,13 @@ def responses_payload( extra_body.pop("thinking", None) payload.update(resolved.get("extra_body") or {}) payload.update(extra_body) + + # A caller-supplied ``reasoning`` object (e.g. ``set_reasoning_effort``) + # overwrites the built ``reasoning``; keep the summary opt-in intact so + # summaries are always requested when reasoning is configured. + if isinstance(payload.get("reasoning"), dict): + payload["reasoning"].setdefault("summary", "auto") + return payload @@ -112,8 +133,12 @@ def to_responses_input( # Replay stashed reasoning items BEFORE the assistant message item # so the provider can continue its OWN encrypted reasoning state # (stateless round-trip: the whole conversation is re-sent). + strip_summary = bool( + (msg.get("provider_specific_fields") or {}).get("use_reasoning_summaries") + ) + for r_item in _stashed_reasoning_items(msg, current_model): - items.append(_reasoning_input_item(r_item)) + items.append(_reasoning_input_item(r_item, strip_summary=strip_summary)) # Assistant turns must use ``output_text`` content blocks; Copilot / # OpenAI reject ``input_text`` on assistant messages with HTTP 400 @@ -255,6 +280,10 @@ def normalize_responses_response(data: Dict[str, Any], model: str) -> Completion for block in item.get("summary") or []: if block.get("type") == "summary_text" and block.get("text"): parts.append(ReasoningPart(text=block["text"])) + # Mark the turn as carrying a readable reasoning summary so + # the replay path can strip it (keeping the id + + # encrypted_content) and keep the cached prefix stable. + provider_fields["use_reasoning_summaries"] = True # Encrypted reasoning (e.g. meta muse-spark): an opaque blob that # must be echoed back verbatim on the next turn. Stash the whole @@ -360,11 +389,17 @@ def parse_responses_chunk(data: Dict[str, Any]) -> Optional[CompletionChunk]: # Reasoning metadata rides on the authoritative completed event; the # per-event ciphertext differs, so only the final items are emitted. if _stream_state["reasoning_items"]: - chunk.provider_specific_fields = { - "reasoning_items": list(_stream_state["reasoning_items"].values()), + reasoning_items = list(_stream_state["reasoning_items"].values()) + provider_fields = { + "reasoning_items": reasoning_items, "reasoning_items_origin": _stream_state.get("model"), } + if any((item.get("summary") or []) for item in reasoning_items): + provider_fields["use_reasoning_summaries"] = True + + chunk.provider_specific_fields = provider_fields + chunk.usage = _build_usage(resp.get("usage") or {}) return chunk @@ -481,13 +516,18 @@ def _stashed_reasoning_items( return [] -def _reasoning_input_item(item: Dict[str, Any]) -> Dict[str, Any]: - """Build the responses-API input item that replays a prior reasoning item.""" +def _reasoning_input_item(item: Dict[str, Any], *, strip_summary: bool = False) -> Dict[str, Any]: + """Build the responses-API input item that replays a prior reasoning item. + + ``strip_summary`` drops the display-only summary text (keeping the id + + encrypted_content) so the reasoning artifact stays in context for the model + without churning the cached prompt prefix with per-turn summary text. + """ return { "type": "reasoning", "id": item.get("id"), "encrypted_content": item.get("encrypted_content"), - "summary": item.get("summary") or [], + "summary": [] if strip_summary else item.get("summary") or [], } diff --git a/cecli/helpers/llms/formatters/thinking.py b/cecli/helpers/llms/formatters/thinking.py index 2e513a9a27d..5a3ba203bfe 100644 --- a/cecli/helpers/llms/formatters/thinking.py +++ b/cecli/helpers/llms/formatters/thinking.py @@ -52,17 +52,30 @@ def gemini_thinking(payload: Dict[str, Any], api_block: Dict[str, Any]) -> Dict[ def anthropic_5_thinking(payload: Dict[str, Any], api_block: Dict[str, Any]) -> Dict[str, Any]: - """Claude 5+: adaptive thinking via ``output_config.effort``.""" + """Claude 5+: adaptive thinking via ``output_config.effort`` + summarized display. + + Claude 5+ defaults ``thinking.display`` to ``"omitted"``, which withholds the + readable thinking summary. Opting in to ``"summarized"`` returns the summary + text so it can be surfaced to the user. + """ if api_block.get("reasoning_effort"): payload["output_config"] = {"effort": api_block["reasoning_effort"]} + payload["thinking"] = {"type": "adaptive", "display": "summarized"} return payload def anthropic_thinking(payload: Dict[str, Any], api_block: Dict[str, Any]) -> Dict[str, Any]: - """Pre-Claude-5: the ``thinking`` block (type enabled + budget).""" - if api_block.get("thinking"): - payload["thinking"] = api_block["thinking"] + """Pre-Claude-5: the ``thinking`` block (type enabled + budget) with summarized display. + + ``display`` works alongside ``type: "enabled"``; defaulting it to + ``"summarized"`` returns the readable thinking summary (older models already + default to it, newer 4.x models default to ``"omitted"``). + """ + thinking = api_block.get("thinking") + + if isinstance(thinking, dict): + payload["thinking"] = {**thinking, "display": "summarized"} return payload diff --git a/cecli/helpers/llms/pipeline.py b/cecli/helpers/llms/pipeline.py index f773a09cd73..b090239a32a 100644 --- a/cecli/helpers/llms/pipeline.py +++ b/cecli/helpers/llms/pipeline.py @@ -71,6 +71,11 @@ async def acompletion( headers = provider.build_headers(resolved, key, family, headers) + # Allow the provider adapter to transform the outgoing message body before + # dispatch (e.g. Mistral's strict schema rejects reasoning_content / + # provider_specific_fields and a null tool-call index). + messages = provider.transform_messages(messages) + if stream: gen = _stream_family(family, resolved, messages, tools, key, headers, kwargs) diff --git a/cecli/helpers/llms/providers/base.py b/cecli/helpers/llms/providers/base.py index cd742023a6e..d6860b493c1 100644 --- a/cecli/helpers/llms/providers/base.py +++ b/cecli/helpers/llms/providers/base.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional class ProviderAdapter: @@ -20,6 +20,10 @@ class ProviderAdapter: authenticated session's ``endpoints.api``). - :meth:`resolve_api_key` - key source (env, auth cache, oauth refresh). - :meth:`build_headers` - auth scheme + provider-specific headers. + - :meth:`transform_messages` - transform the outgoing message body to + normalize fields a stricter provider rejects (e.g. Mistral rejects + ``reasoning_content`` / ``provider_specific_fields`` and a null tool-call + ``index``). - :meth:`normalize` - post-process a family-normalized response (e.g. meta encrypted-reasoning marker). """ @@ -53,6 +57,17 @@ def build_headers( merged.setdefault("Content-Type", "application/json") return merged + def transform_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Transform the outgoing message body to normalize provider-specific fields. + + The default is a no-op. Providers with a stricter request schema override + this to strip fields the generic OpenAI-compatible wire tolerates but the + provider rejects (e.g. Mistral rejects ``reasoning_content`` and + ``provider_specific_fields`` on assistant turns, and a null tool-call + ``index``). + """ + return messages + def normalize( self, family: str, diff --git a/cecli/helpers/llms/providers/mistral.py b/cecli/helpers/llms/providers/mistral.py new file mode 100644 index 00000000000..4f1415db367 --- /dev/null +++ b/cecli/helpers/llms/providers/mistral.py @@ -0,0 +1,79 @@ +"""Mistral provider adapter for the llms package. + +Mistral speaks OpenAI-compatible /v1/chat/completions with Bearer auth, but its +request body is validated by a strict Pydantic schema that rejects fields the +generic OpenAI-compatible wire tolerates. Assistant turns built by cecli carry +``reasoning_content`` and ``provider_specific_fields`` on the message, and +``provider_specific_fields`` plus a null ``index`` on tool calls (a streaming +artifact); Mistral rejects each of these with a 422 on the relevant +``messages[i]`` path. + +This adapter iterates over the message body before dispatch and strips those +fields, leaving tool calls in the request wire shape (``id`` / ``type`` / +``function``). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from .base import ProviderAdapter + + +class MistralProvider(ProviderAdapter): + """Mistral: Bearer auth (default) + strict message-body sanitization.""" + + provider: str = "mistral" + + def transform_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Strip fields Mistral's strict chat-completions schema rejects.""" + changed = False + out: List[Dict[str, Any]] = [] + + for msg in messages: + cleaned = self._clean_message(msg) + + if cleaned is not None: + out.append(cleaned) + changed = True + else: + out.append(msg) + + return out if changed else messages + + def _clean_message(self, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return a sanitized copy of ``msg``, or ``None`` when unchanged.""" + tool_calls = msg.get("tool_calls") + needs_tool_fix = any( + isinstance(tc, dict) and ("index" in tc or "provider_specific_fields" in tc) + for tc in tool_calls or [] + ) + + if ( + "reasoning_content" not in msg + and "provider_specific_fields" not in msg + and not needs_tool_fix + ): + return None + + cleaned = dict(msg) + cleaned.pop("reasoning_content", None) + cleaned.pop("provider_specific_fields", None) + + if tool_calls: + cleaned["tool_calls"] = [self._clean_tool_call(tc) for tc in tool_calls] + + return cleaned + + def _clean_tool_call(self, tc: Any) -> Any: + """Drop streaming-only fields from a tool call.""" + if not isinstance(tc, dict): + return tc + + cleaned = dict(tc) + cleaned.pop("index", None) + cleaned.pop("provider_specific_fields", None) + return cleaned + + +__all__ = ["MistralProvider"] diff --git a/cecli/helpers/model_providers.py b/cecli/helpers/model_providers.py index 675436c0dec..0b2f29b1d80 100644 --- a/cecli/helpers/model_providers.py +++ b/cecli/helpers/model_providers.py @@ -173,6 +173,28 @@ def get_models_for_listing(self) -> Dict[str, Dict]: listings[model_id] = info return listings + def get_provider_models(self, provider: str) -> Dict[str, Dict]: + """Return ``{model_id: info}`` for a single provider. + + Unlike :meth:`get_models_for_listing`, this only resolves and fetches the + requested provider, so it is safe to call during onboarding right after + an API key has been supplied (the key must already be in the environment). + """ + if not provider or not self._ensure_provider_state(provider): + return {} + content = self._ensure_content(provider) + if not content or "data" not in content: + return {} + listings = {} + for record in content["data"]: + model_id = record.get("id") + if not model_id: + continue + info = self._record_to_info(record, provider) + if info: + listings[model_id] = info + return listings + def refresh_provider_cache(self, provider: str) -> bool: if not self._ensure_provider_state(provider): return False diff --git a/cecli/helpers/observations/service.py b/cecli/helpers/observations/service.py index 3759bf9f2e5..ce4f9332b96 100644 --- a/cecli/helpers/observations/service.py +++ b/cecli/helpers/observations/service.py @@ -3,6 +3,7 @@ from datetime import datetime from cecli.helpers.conversation.service import ConversationService +from cecli.helpers.coroutines import fire_and_forget class ObservationService: @@ -54,9 +55,10 @@ async def check_and_trigger(self): cur_messages = ConversationService.get_manager(coder).get_messages_dict() - # Calculate unobserved tokens - unobserved = cur_messages[self._last_observed_index :] - current_index = len(cur_messages) + # Capture the range passed to the background task before new messages arrive. + snapshot_index = len(cur_messages) + unobserved = cur_messages[self._last_observed_index : snapshot_index] + current_index = snapshot_index if not unobserved: return @@ -67,8 +69,11 @@ async def check_and_trigger(self): tokens >= self.observation_threshold and (not self._last_observed_index or current_index - self._last_observed_index >= 10) ) or tokens >= 2 * self.observation_threshold: - asyncio.create_task(self.run_observation(unobserved)) - self._last_observed_index = len(cur_messages) + # Mark as processing before scheduling so a concurrent check cannot + # enqueue a second overlapping observation. + self.is_processing = True + fire_and_forget(self.run_observation(unobserved)) + self._last_observed_index = snapshot_index async def run_observation(self, messages): coder = self.get_coder() @@ -88,6 +93,9 @@ async def run_observation(self, messages): prompt += "\n\n---\nCURRENT OBSERVATIONS (Do not duplicate):\n\n" prompt += "\n".join(self.observations) + # Throttle the observation API call to respect the per-minute token limit. + await coder._rate_limit_sleep() + observation = await coder.summarizer.summarize_all_as_text( all_messages, prompt, max_tokens=8192, coder=coder ) @@ -122,9 +130,11 @@ async def run_reflection(self): # Prepare observations for the reflector obs_text = "\n".join([f"- {o}" for o in self.observations]) + reflection_index = len(ConversationService.get_manager(coder).get_messages_dict()) # Use the Reflector to condense and get next steps reflection_prompt = coder.gpt_prompts.reflection_prompt + await coder._rate_limit_sleep() reflection = await coder.summarizer.summarize_all_as_text( [{"role": "user", "content": obs_text}], reflection_prompt, @@ -135,7 +145,11 @@ async def run_reflection(self): # 1. Internal State Update: Store the condensed log internally self.observations = [reflection] - self._last_observed_index = 0 + # Keep the observation pointer at the current end of the message log + # rather than resetting it to 0, so the next check_and_trigger() + # observes only newly-arrived messages instead of re-reading the + # entire, already-condensed conversation from the start. + self._last_observed_index = max(self._last_observed_index, reflection_index) except asyncio.CancelledError: raise except Exception as e: diff --git a/cecli/helpers/onboarding/__init__.py b/cecli/helpers/onboarding/__init__.py new file mode 100644 index 00000000000..ddca8ea6890 --- /dev/null +++ b/cecli/helpers/onboarding/__init__.py @@ -0,0 +1,99 @@ +"""Inline onboarding wizard for cecli. + +When a user has no default model configured and no API keys are detected, +:func:`run_onboarding` launches a full-screen Textual picker that lets them: + + #. pick a model provider (OpenAI, Anthropic, or any provider from ``providers.json``) + #. enter the provider's ``_API_KEY`` environment variable(s) + #. pick a default model + +The entered keys are persisted to ``~/.cecli/.env`` and the chosen default +model is persisted to ``~/.cecli/conf.yml`` so future sessions load them +automatically. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from .providers import iter_providers + + +def _env_file() -> Path: + return Path.home() / ".cecli" / ".env" + + +def _conf_file() -> Path: + return Path.home() / ".cecli" / "conf.yml" + + +def _persist_api_keys(api_keys: Optional[Dict[str, str]]) -> None: + if not api_keys: + return + + import dotenv + + env_file = _env_file() + env_file.parent.mkdir(parents=True, exist_ok=True) + for name, value in api_keys.items(): + dotenv.set_key(str(env_file), name, value, quote_mode="always") + + +def _persist_default_model(model: str) -> None: + import yaml + + conf_file = _conf_file() + conf_file.parent.mkdir(parents=True, exist_ok=True) + + config = {} + if conf_file.exists(): + try: + with conf_file.open("r", encoding="utf-8") as f: + content = yaml.safe_load(f) + if isinstance(content, dict): + config = content + except Exception: + config = {} + + config["model"] = model + config["agent"] = True + + with conf_file.open("w", encoding="utf-8") as f: + yaml.safe_dump(config, f, sort_keys=False, default_flow_style=False) + + +async def run_onboarding(io) -> Optional[str]: + """Run the onboarding wizard and return the chosen default model name. + + Returns ``None`` when the user cancels or the wizard cannot be launched + (for example when ``textual`` is not installed). + """ + try: + from .app import OnboardingApp + except ImportError as e: + io.tool_error("Onboarding requires the 'textual' package.") + io.tool_output(f"Install with: pip install cecli-dev[tui] ({e})") + return None + + if sys.stdin is None or not sys.stdin.isatty(): + return None + + providers: List[Dict] = iter_providers() + app = OnboardingApp(providers) + + try: + result = await app.run_async() + except Exception as e: + io.tool_error(f"Onboarding failed: {e}") + return None + + if not result: + return None + + try: + _persist_api_keys(result.get("api_keys") or {}) + _persist_default_model(result["model"]) + except Exception as e: + io.tool_warning(f"Failed to persist onboarding settings: {e}") + + return result["model"] diff --git a/cecli/helpers/onboarding/app.py b/cecli/helpers/onboarding/app.py new file mode 100644 index 00000000000..24709ee27d0 --- /dev/null +++ b/cecli/helpers/onboarding/app.py @@ -0,0 +1,362 @@ +"""Textual onboarding wizard for cecli. + +Runs an inline full-screen picker that lets a user choose a model provider, +enter the relevant API key(s), and pick a default model. The collected values +are returned to :func:`cecli.helpers.onboarding.run_onboarding` for +persistence to ``~/.cecli/.env`` and ``~/.cecli/conf.yml``. +""" + +import os +from functools import lru_cache +from typing import Dict, List, Optional + +import textual.strip +from rich.color import ColorSystem +from rich.style import Style +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.message import Message +from textual.screen import Screen +from textual.widgets import Footer, Input, OptionList, Static +from textual.widgets.option_list import Option + +from .providers import get_models_for_provider, provider_display, provider_needs_key + +# Sentinel returned by a screen to signal "go back a step" rather than cancel. +_BACK = object() + + +class FilterableList(Vertical): + """A search-as-you-type input paired with an option list.""" + + BINDINGS = [ + Binding("up", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + ] + + class Selected(Message): + """Posted when the user selects an option.""" + + def __init__(self, value) -> None: + super().__init__() + self.value = value + + def __init__( + self, values: List, label_fn=None, prompt: str = "Search...", id: Optional[str] = None + ): + super().__init__(id=id) + self.values = list(values) + self.label_fn = label_fn or (lambda v: str(v)) + self._visible = list(self.values) + self.prompt = prompt + + def compose(self) -> ComposeResult: + yield Input(placeholder=self.prompt, id="filter") + yield OptionList(id="options") + + def on_mount(self) -> None: + self._render_options() + self.query_one("#filter", Input).focus() + + def _render_options(self) -> None: + options = self.query_one("#options", OptionList) + options.clear_options() + for value in self._visible: + options.add_option(Option(self.label_fn(value))) + if self._visible: + options.highlighted = 0 + + def on_input_changed(self, event) -> None: + query = event.value.strip().lower() + if query: + self._visible = [v for v in self.values if query in self.label_fn(v).lower()] + else: + self._visible = list(self.values) + self._render_options() + + def on_input_submitted(self, event) -> None: + self._select_highlighted() + + def _select_highlighted(self) -> None: + options = self.query_one("#options", OptionList) + index = options.highlighted + if index is not None and 0 <= index < len(self._visible): + self.post_message(self.Selected(self._visible[index])) + + def action_move_up(self) -> None: + self.query_one("#options", OptionList).action_cursor_up() + + def action_move_down(self) -> None: + self.query_one("#options", OptionList).action_cursor_down() + + def on_option_list_option_selected(self, event) -> None: + index = event.option_index + if index is not None and 0 <= index < len(self._visible): + self.post_message(self.Selected(self._visible[index])) + + +class ProviderScreen(Screen): + """First step: pick a provider.""" + + def __init__(self, providers: List[Dict]) -> None: + super().__init__() + self.providers = providers + + def compose(self) -> ComposeResult: + yield Static("Pick a model provider") + yield FilterableList( + self.providers, label_fn=provider_display, prompt="Search providers...", id="providers" + ) + yield Footer() + + def on_filterable_list_selected(self, event) -> None: + self.dismiss(event.value) + + +class ApiKeyScreen(Screen): + """Second step: enter the provider's API key(s).""" + + BINDINGS = [Binding("escape", "back", "Back")] + + def __init__( + self, provider: Dict, env_vars: List[str], collected: Optional[Dict] = None + ) -> None: + super().__init__() + self.provider = provider + self.env_vars = list(env_vars) + self.index = 0 + self.collected = dict(collected or {}) + + def action_back(self) -> None: + self.dismiss(_BACK) + + def compose(self) -> ComposeResult: + yield Static(id="prompt") + yield Input(password=True, placeholder="", id="key") + yield Static("Press Enter to continue", id="hint") + yield Footer() + + def on_mount(self) -> None: + self._refresh() + + def _refresh(self) -> None: + env_var = self.env_vars[self.index] + self.query_one("#prompt", Static).update(f"Enter your {env_var}") + self.query_one("#key", Input).placeholder = env_var + self.query_one("#key", Input).focus() + + def on_input_submitted(self, event) -> None: + value = event.value.strip() + if not value: + self.query_one("#key", Input).focus() + return + + self.collected[self.env_vars[self.index]] = value + self.index += 1 + if self.index < len(self.env_vars): + self.query_one("#key", Input).value = "" + self._refresh() + else: + self.dismiss(self.collected) + + +class LoadingScreen(Screen): + """Transient screen shown while provider models are fetched.""" + + def compose(self) -> ComposeResult: + yield Static("Fetching available models...") + yield Footer() + + +class ModelScreen(Screen): + """Model picker (when provider discovery returns models).""" + + def __init__(self, models: List[str]) -> None: + super().__init__() + self.models = models + + def compose(self) -> ComposeResult: + yield Static("Pick a default model") + yield FilterableList(self.models, prompt="Search models...", id="models") + yield Footer() + + def on_filterable_list_selected(self, event) -> None: + self.dismiss(event.value) + + +class ModelManualScreen(Screen): + """Manual model entry (when provider discovery returns no models).""" + + def __init__(self, provider: Dict) -> None: + super().__init__() + self.provider = provider + + def compose(self) -> ComposeResult: + yield Static( + f"No models found for '{self.provider['display_name']}'. Enter a model name manually:" + ) + yield Input(placeholder="model name", id="model") + yield Footer() + + def on_input_submitted(self, event) -> None: + value = event.value.strip() + if value: + self.dismiss(value) + + +class OnboardingApp(App): + """Inline onboarding wizard.""" + + CSS = """ + FilterableList { + height: 1fr; + padding: 0 1; + } + FilterableList OptionList { + height: 1fr; + border: round #00ff87 50%; + scrollbar-size-vertical: 1; + scrollbar-size-horizontal: 1; + } + FilterableList OptionList > .option-list--option-highlighted { + color: #00b365; + background: transparent; + text-style: bold; + } + FilterableList OptionList:focus > .option-list--option-highlighted { + color: #00b365; + background: transparent; + text-style: bold; + } + Input { + border: round #00ff87 50%; + margin: 0; + } + Static { + margin: 0 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+c", "cancel", "Cancel"), + ] + + def __init__(self, providers: List[Dict]) -> None: + super().__init__() + self.providers = providers + self.provider: Optional[Dict] = None + self.api_keys: Dict[str, str] = {} + self.result = None + + def on_mount(self) -> None: + self.push_screen(ProviderScreen(self.providers), self._on_provider) + + def action_cancel(self) -> None: + self.result = None + self.exit(None) + + def _on_provider(self, provider) -> None: + if provider is None: + self.exit(None) + return + self.provider = provider + env_vars = provider.get("api_key_env") or [] + if ( + provider_needs_key(provider) + and env_vars + and not all(os.environ.get(v) for v in env_vars) + ): + self.push_screen(ApiKeyScreen(provider, env_vars), self._on_api_keys) + else: + present = {v: os.environ[v] for v in env_vars if os.environ.get(v)} + self._on_api_keys(present) + + def _on_api_keys(self, keys) -> None: + if keys is _BACK: + self._back_to_provider() + return + if keys is None: + self.exit(None) + return + self.api_keys = keys or {} + for name, value in self.api_keys.items(): + os.environ[name] = value + self._fetch_models() + + def _back_to_provider(self) -> None: + self.provider = None + self.api_keys = {} + self.push_screen(ProviderScreen(self.providers), self._on_provider) + + def _fetch_models(self) -> None: + slug = self.provider["slug"] + self.push_screen(LoadingScreen()) + + def worker() -> None: + try: + models = get_models_for_provider(slug) + except Exception: + models = [] + self.call_from_thread(self._finish_fetch, models) + + self.run_worker(worker, thread=True) + + def _finish_fetch(self, models) -> None: + if self.screen is not None: + self.pop_screen() + self._on_models(models) + + def _on_models(self, models) -> None: + if not models: + self.push_screen(ModelManualScreen(self.provider), self._on_model) + else: + self.push_screen(ModelScreen(models), self._on_model) + + def _on_model(self, model) -> None: + if model is None: + self.result = None + self.exit(None) + return + self.result = { + "provider": self.provider["slug"], + "api_keys": self.api_keys, + "model": model, + } + self.exit(self.result) + + +def patch_textual_strip_render_with_cache(): + """Monkey-patch Textual's ANSI renderer to ignore background colors. + + Applied permanently at import time so the inline onboarding wizard keeps the + terminal's own background rather than cecli's dark theme painting over it. + """ + + def modified_render_ansi(cls, style: Style, color_system: ColorSystem) -> str: + """Modified ANSI generator that ignores background colors.""" + sgr: list[str] + if attributes := style._attributes & style._set_attributes: + _style_map = textual.strip.SGR_STYLES + sgr = [ + _style_map[bit_offset] + for bit_offset in range(attributes.bit_length()) + if attributes & (1 << bit_offset) + ] + else: + sgr = [] + + if (color := style._color) is not None: + sgr.extend(color.downgrade(color_system).get_ansi_codes()) + + # BACKGROUND OVERRIDE: Skip the bgcolor block entirely + ansi = style._ansi = ";".join(sgr) + return ansi + + cached_version = lru_cache(maxsize=16384)(modified_render_ansi) + textual.strip.Strip.render_ansi = classmethod(cached_version) + + +# Apply the no-background-color hack permanently for the onboarding wizard. +patch_textual_strip_render_with_cache() diff --git a/cecli/helpers/onboarding/providers.py b/cecli/helpers/onboarding/providers.py new file mode 100644 index 00000000000..5cd27099c5b --- /dev/null +++ b/cecli/helpers/onboarding/providers.py @@ -0,0 +1,149 @@ +"""Provider and model discovery for the onboarding wizard.""" + +import importlib.resources as importlib_resources +import json +import os +from typing import Dict, List + +# Providers that authenticate through mechanisms other than a simple _API_KEY +# (e.g. GitHub Copilot tokens or AWS credentials), so onboarding should never +# prompt for a key and instead jump straight to model selection. +NO_KEY_SLUGS = {"github_copilot", "bedrock", "bedrock_mantle"} + +# Friendly display names for the default providers that are not covered by +# providers.json. +_PROVIDER_DISPLAY: Dict[str, str] = { + "openai": "OpenAI", + "anthropic": "Anthropic", + "deepseek": "DeepSeek", + "openrouter": "OpenRouter", + "gemini": "Google Gemini", + "github_copilot": "GitHub Copilot", + "meta": "Meta", +} + +# Builtin providers used as a fallback if the llms config is unavailable. +BUILTIN_PROVIDERS: List[Dict] = [ + {"slug": "openai", "display_name": "OpenAI", "api_key_env": ["OPENAI_API_KEY"]}, + {"slug": "anthropic", "display_name": "Anthropic", "api_key_env": ["ANTHROPIC_API_KEY"]}, + { + "slug": "github_copilot", + "display_name": "GitHub Copilot", + "api_key_env": ["GITHUB_COPILOT_TOKEN"], + }, +] + +# The bundled metadata resources used for model discovery. The ext file is a +# superset, so later entries win when both are merged. +_METADATA_RESOURCES = ("model-metadata.json", "model-metadata.ext.json") + + +def _provider_configs() -> Dict: + from cecli.helpers.model_providers import PROVIDER_CONFIGS + + return PROVIDER_CONFIGS + + +def _load_metadata() -> Dict: + """Merge the bundled model metadata resources into one ``{model: info}`` dict.""" + data = {} + for name in _METADATA_RESOURCES: + try: + resource = importlib_resources.files("cecli.resources").joinpath(name) + except (FileNotFoundError, ModuleNotFoundError): + continue + try: + entries = json.loads(resource.read_text()) + except (json.JSONDecodeError, OSError): + continue + if isinstance(entries, dict): + data.update(entries) + return data + + +def _models_from_metadata(data: Dict, slug: str) -> List[str]: + """Return selectable model names for a provider from the metadata dict.""" + models = [] + for name, meta in data.items(): + if not isinstance(meta, dict): + continue + provider = meta.get("litellm_provider") or "" + if provider == slug: + models.append(name if name.startswith(slug + "/") else f"{slug}/{name}") + elif name.startswith(slug + "/"): + models.append(name) + return models + + +def iter_providers() -> List[Dict]: + """Return the list of providers selectable during onboarding.""" + providers: Dict[str, Dict] = {} + + # Default providers from the llms config (openai, anthropic, deepseek, + # openrouter, gemini, github_copilot, meta, chutes, opencode-*). + try: + from cecli.helpers.llms.config import PROVIDER_DEFAULTS + except ImportError: + PROVIDER_DEFAULTS = None + + if PROVIDER_DEFAULTS: + for slug, cfg in PROVIDER_DEFAULTS.items(): + key_env = cfg.get("api_key_env") + key_env = [key_env] if isinstance(key_env, str) and key_env else [] + providers[slug] = { + "slug": slug, + "display_name": _PROVIDER_DISPLAY.get(slug) or slug, + "api_key_env": key_env, + } + else: + for entry in BUILTIN_PROVIDERS: + providers[entry["slug"]] = dict(entry) + + # Custom providers from providers.json override the defaults where present. + for slug, cfg in _provider_configs().items(): + providers[slug] = { + "slug": slug, + "display_name": cfg.get("display_name", slug), + "api_key_env": list(cfg.get("api_key_env", []) or []), + } + + return list(providers.values()) + + +def provider_has_key(entry: Dict) -> bool: + """Return True if the provider already has a key in the environment.""" + return any(os.environ.get(env_var) for env_var in entry.get("api_key_env") or []) + + +def provider_needs_key(entry: Dict) -> bool: + """Return True if onboarding should prompt the user for an API key.""" + return entry["slug"] not in NO_KEY_SLUGS + + +def provider_display(entry: Dict) -> str: + """Return the display label for a provider entry.""" + label = entry.get("display_name", entry["slug"]) + if provider_has_key(entry): + return f"{label} (key set)" + return label + + +def get_models_for_provider(slug: str) -> List[str]: + """Return the selectable model names for a provider.""" + models = _models_from_metadata(_load_metadata(), slug) + if models: + return sorted(set(models)) + + # Fall back to a live fetch for providers absent from the bundled metadata. + from cecli.models import model_info_manager + + manager = model_info_manager.provider_manager + if manager.supports_provider(slug): + try: + manager.refresh_provider_cache(slug) + except Exception: + pass + content = manager.get_provider_models(slug) + if content: + return sorted(f"{slug}/{model_id}" for model_id in content) + return [] diff --git a/cecli/history.py b/cecli/history.py index abf2b1eb103..1fb84b9c778 100644 --- a/cecli/history.py +++ b/cecli/history.py @@ -125,7 +125,7 @@ async def summarize_all(self, messages): for model in self.models: try: - summary = await model.simple_send_with_retries(summarize_messages) + summary, _ = await model.simple_send_with_retries(summarize_messages) if summary is not None: summary = prompts.summary_prefix + summary return [dict(role="user", content=summary)] @@ -141,7 +141,7 @@ async def summarize_all_as_text(self, messages, prompt, max_tokens=None, coder=N for model in self.models: try: - summary = await model.simple_send_with_retries( + summary, _ = await model.simple_send_with_retries( messages, max_tokens=max_tokens, coder=coder ) if summary is not None: diff --git a/cecli/hooks/helpers.py b/cecli/hooks/helpers.py index 5731088476d..a4bacfc20e8 100644 --- a/cecli/hooks/helpers.py +++ b/cecli/hooks/helpers.py @@ -153,12 +153,13 @@ async def call( else: model = coder.main_model - return await model.simple_send_with_retries( + content, _ = await model.simple_send_with_retries( messages=messages, max_tokens=max_tokens, coder=coder, override_kwargs=kwargs, ) + return content @staticmethod async def call_subagent( diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 75fbf9aa374..a38ab2ebd12 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -679,12 +679,10 @@ async def sdk2_callback_handler() -> AuthorizationCodeResult: def _unpack_transport(transport): """Return (read, write) streams from an HTTP transport. - mcp SDK 1.x yields a 3-tuple (read, write, session_id_getter); SDK 2.x - yields a 2-tuple (read, write). + streamable_http_client on mcp SDK 1.x yields a 3-tuple + (read, write, session_id_getter); SDK 2.x and sse_client (all versions) + yield a 2-tuple (read, write). Unpack by length rather than guessing + from the SDK version. """ - if _get_mcp_major_version() >= 2: - read, write = transport - else: - read, write, _ = transport - + read, write = transport[0], transport[1] return read, write diff --git a/cecli/models.py b/cecli/models.py index 0e2809fe1a9..5200f8a8591 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1316,6 +1316,7 @@ async def send_completion( max_wait=2, override_kwargs={}, interrupt_event=None, + uuid=None, ): import random @@ -1416,7 +1417,7 @@ async def send_completion( self._log_messages(messages) kwargs["messages"] = messages - kwargs["prompt_cache_key"] = GLOBAL_ID + kwargs["prompt_cache_key"] = uuid or GLOBAL_ID if not self.is_anthropic() and not self.caches_by_default: kwargs["cache_control_injection_points"] = [ @@ -1566,6 +1567,12 @@ async def simple_send_with_retries( while True: try: + if coder: + rate_limit_sleep = getattr(coder, "_rate_limit_sleep", None) + if callable(rate_limit_sleep): + result = rate_limit_sleep(self) + if asyncio.iscoroutine(result): + await result _hash, response = await self.send_completion( messages=messages, @@ -1575,6 +1582,7 @@ async def simple_send_with_retries( tools=tools, max_tokens=max_tokens, override_kwargs=override_kwargs, + uuid=nested.getter(coder, "uuid"), ) if ( not response @@ -1583,11 +1591,14 @@ async def simple_send_with_retries( or nested.getter(response, "choices.0.message.content") == nested.getter(self.model_error_response(), "choices.0.message.content") ): - return None + return None, None res = response.choices[0].message.content from cecli.reasoning_tags import remove_reasoning_content - return remove_reasoning_content(res, self.reasoning_tag) + if coder: + coder.record_background_usage_and_cost(messages, response, model=self) + + return remove_reasoning_content(res, self.reasoning_tag), response except litellm_ex.exceptions_tuple() as err: ex_info = litellm_ex.get_ex_info(err) print(str(err)) @@ -1605,16 +1616,14 @@ async def simple_send_with_retries( should_retry = False if not should_retry: - return None + return None, None print(f"Retrying in {retry_delay:.1f} seconds...") time.sleep(retry_delay) continue except AttributeError: - return None + return None, None except KeyboardInterrupt: - # An interrupt was not caught within the async run loop. - # We'll just pass to allow the thread to exit gracefully - # without a scary traceback. + # We'll just pass to allow the thread to exit gracefully. pass def model_error_response(self): @@ -1716,14 +1725,14 @@ def _extract_retry_delay(self, err): # 2. Check HTTP headers fallback (retry-after, retry-after-ms) headers = nested.getter(err, ["response.headers", "headers"], None) if headers is not None: - retry_after = nested.getter(headers, ["retry-after"], None) + retry_after = nested.getter(headers, ["retry-after", "Retry-After"], None) if retry_after is not None: try: return float(str(retry_after).strip()) except (ValueError, TypeError): pass - retry_after_ms = nested.getter(headers, ["retry-after-ms"], None) + retry_after_ms = nested.getter(headers, ["retry-after-ms", "Retry-After-Ms"], None) if retry_after_ms is not None: try: return float(str(retry_after_ms).strip()) / 1000.0 diff --git a/cecli/onboarding.py b/cecli/onboarding.py index 0e55b0d8879..5fdcfc893f1 100644 --- a/cecli/onboarding.py +++ b/cecli/onboarding.py @@ -50,15 +50,15 @@ def try_to_select_default_model(): if openrouter_key: is_free_tier = check_openrouter_tier(openrouter_key) if is_free_tier: - return "openrouter/deepseek/deepseek-r1:free" + return "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free" else: - return "openrouter/anthropic/claude-sonnet-4" + return "openrouter/anthropic/claude-sonnet-5" model_key_pairs = [ - ("ANTHROPIC_API_KEY", "sonnet"), - ("DEEPSEEK_API_KEY", "deepseek"), - ("OPENAI_API_KEY", "gpt-4o"), - ("GEMINI_API_KEY", "gemini/gemini-2.5-pro-exp-03-25"), - ("VERTEXAI_PROJECT", "vertex_ai/gemini-2.5-pro-exp-03-25"), + ("ANTHROPIC_API_KEY", "anthropic/claude-sonnet-5"), + ("DEEPSEEK_API_KEY", "deepseek-v4-flash"), + ("OPENAI_API_KEY", "gpt-5.6-luna"), + ("GEMINI_API_KEY", "gemini/gemini-3.8-flash"), + ("VERTEXAI_PROJECT", "gemini/gemini-3.8-flash"), ] for env_key, model_name in model_key_pairs: api_key_value = os.environ.get(env_key) @@ -92,7 +92,7 @@ async def offer_openrouter_oauth(io): async def select_default_model(args, io): """ Selects a default model based on available API keys if no model is specified. - Offers OAuth flow for OpenRouter if no keys are found. + Launches the inline onboarding wizard when no model or API keys are found. Args: args: The command line arguments object. @@ -109,10 +109,13 @@ async def select_default_model(args, io): return model no_model_msg = "No LLM model was specified and no API keys were provided." io.tool_warning(no_model_msg) - await offer_openrouter_oauth(io) - model = try_to_select_default_model() + + from cecli.helpers.onboarding import run_onboarding + + model = await run_onboarding(io) if model: return model + await io.offer_url(urls.models_and_keys, "Open documentation URL for more info?") diff --git a/cecli/repo.py b/cecli/repo.py index c0481051427..679ac42262d 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -279,7 +279,9 @@ async def commit(self, fnames=None, context=None, message=None, coder_edits=Fals user_language = coder.commit_language if not user_language: user_language = coder.get_user_language() - commit_message = await self.get_commit_message(diffs, context, user_language) + commit_message = await self.get_commit_message( + diffs, context, user_language, coder=coder + ) # Retrieve attribute settings, prioritizing coder.args if available if coder and hasattr(coder, "args") and coder.args: @@ -413,7 +415,7 @@ def get_rel_fname(self, fname): except ValueError: return fname - async def get_commit_message(self, diffs, context, user_language=None): + async def get_commit_message(self, diffs, context, user_language=None, coder=None): diffs = "# Diffs:\n" + diffs content = "" @@ -422,7 +424,6 @@ async def get_commit_message(self, diffs, context, user_language=None): content += diffs system_content = self.commit_prompt or prompts.commit_system - language_instruction = "" if user_language: language_instruction = f"\n- Is written in {user_language}." @@ -431,7 +432,6 @@ async def get_commit_message(self, diffs, context, user_language=None): commit_message = None for model in self.models: spinner_text = f"Generating commit message with {model.name}\n" - self.io.start_spinner(spinner_text, update_last_text=False) if model.system_prompt_prefix: @@ -446,20 +446,20 @@ async def get_commit_message(self, diffs, context, user_language=None): num_tokens = model.token_count(messages) max_tokens = model.info.get("max_input_tokens") or 0 - if max_tokens and num_tokens > max_tokens: continue - commit_message = await model.simple_send_with_retries( + commit_message, _ = await model.simple_send_with_retries( messages, override_kwargs={ "reasoning_effort": None, "thinking": None, "drop_params": True, }, + coder=coder, ) if commit_message: - break # Found a model that could generate the message + break if not commit_message: self.io.tool_error("Failed to generate commit message!") diff --git a/cecli/resources/model-metadata.json b/cecli/resources/model-metadata.json index 59f05dcaa75..c971e9c5272 100644 --- a/cecli/resources/model-metadata.json +++ b/cecli/resources/model-metadata.json @@ -4107,6 +4107,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/gpt-6-astra": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens": 0.000025, + "cache_read_input_token_cost": 0.000001, + "cache_read_input_token_cost_above_272k_tokens": 0.000002, + "input_cost_per_token": 0.00001, + "input_cost_per_token_above_272k_tokens": 0.00002, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "output_cost_per_token_above_272k_tokens": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": false, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/gpt-audio-1.5-2026-02-23": { "deprecation_date": "2027-08-24", "input_cost_per_audio_token": 0.00004, @@ -4587,6 +4634,56 @@ "supports_tool_choice": true, "supports_vision": true }, + "azure/us-gov/gpt-5.1": { + "cache_read_input_token_cost": 1.71875e-7, + "default_reasoning_effort": "none", + "input_cost_per_token": 0.00000171875, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00001375, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true + }, + "azure/us-gov/o3-mini": { + "cache_read_input_token_cost": 7.57e-7, + "input_cost_per_token": 0.000001513, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 0.00000605, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "azure/us/gpt-4.1-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5.5e-7, @@ -4656,7 +4753,7 @@ "supports_web_search": false }, "azure/us/gpt-4.1-nano-2025-04-14": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.5e-8, "input_cost_per_token": 1.1e-7, "input_cost_per_token_batches": 6e-8, @@ -5300,6 +5397,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/us/gpt-6-astra": { + "cache_creation_input_token_cost": 0.00001375, + "cache_creation_input_token_cost_above_272k_tokens": 0.0000275, + "cache_read_input_token_cost": 0.0000011, + "cache_read_input_token_cost_above_272k_tokens": 0.0000022, + "input_cost_per_token": 0.000011, + "input_cost_per_token_above_272k_tokens": 0.000022, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000055, + "output_cost_per_token_above_272k_tokens": 0.0000825, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": false, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/us/o1-2024-12-17": { "cache_read_input_token_cost": 0.00000825, "deprecation_date": "2026-10-21", @@ -5411,6 +5555,42 @@ "supports_tool_choice": true, "supports_vision": true }, + "azure_ai/Codestral-2501": { + "input_cost_per_token": 3e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 256000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 9e-7, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/mistral/", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_native_streaming": true + }, + "azure_ai/DeepSeek-V4-Flash-0731": { + "cache_read_input_token_cost": 1.4e-8, + "deprecation_date": "2026-12-03", + "input_cost_per_token": 4.4e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00000132, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure_ai/FW-DeepSeek-V3.2": { "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 3.1e-7, @@ -5683,6 +5863,26 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "azure_ai/FW-Nemotron-Lightning-3.5-30B-A3B": { + "cache_read_input_token_cost": 1e-8, + "input_cost_per_token": 6e-8, + "litellm_provider": "azure_ai", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2.2e-7, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, "azure_ai/Llama-3.2-11B-Vision-Instruct": { "deprecation_date": "2026-06-13", "input_cost_per_token": 3.7e-7, @@ -5761,6 +5961,30 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "azure_ai/MAI-Thinking-1": { + "cache_read_input_token_cost": 2e-7, + "input_cost_per_token": 0.000002, + "litellm_provider": "azure_ai", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000008, + "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure_ai/Meta-Llama-3-70B-Instruct": { "input_cost_per_token": 0.0000011, "litellm_provider": "azure_ai", @@ -6043,7 +6267,8 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "deprecation_date": "2027-12-05" }, "azure_ai/claude-haiku-4-5": { "deprecation_date": "2026-10-19", @@ -6421,23 +6646,9 @@ "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true - }, - "azure_ai/deepseek-v4-flash-0731": { + "supports_tool_choice": true, "cache_read_input_token_cost": 2.8e-8, - "deprecation_date": "2026-12-03", - "input_cost_per_token": 1.9e-7, - "litellm_provider": "azure_ai", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 5.1e-7, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true + "supports_prompt_caching": true }, "azure_ai/deepseek-v4-pro": { "deprecation_date": "2028-02-20", @@ -6451,7 +6662,9 @@ "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "cache_read_input_token_cost": 1.45e-7, + "supports_prompt_caching": true }, "azure_ai/global/grok-3": { "deprecation_date": "2026-05-01", @@ -6841,6 +7054,55 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure_ai/gpt-6-astra": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens": 0.000025, + "cache_read_input_token_cost": 0.000001, + "cache_read_input_token_cost_above_272k_tokens": 0.000002, + "input_cost_per_token": 0.00001, + "input_cost_per_token_above_272k_tokens": 0.00002, + "litellm_provider": "azure_ai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "output_cost_per_token_above_272k_tokens": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://ai.azure.com/catalog/models/gpt-6-astra", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": false, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure_ai/gpt-oss-120b": { "input_cost_per_token": 1.5e-7, "output_cost_per_token": 6e-7, @@ -6976,6 +7238,24 @@ "supports_vision": true, "supports_web_search": true }, + "azure_ai/grok-4.6": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000002, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000006, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "azure_ai/grok-code-fast-1": { "input_cost_per_token": 2e-7, "litellm_provider": "azure_ai", @@ -7019,11 +7299,13 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 0.000003, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/kimi-k2-5-now-in-microsoft-foundry/4492321", + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", "supports_function_calling": true, "supports_tool_choice": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1e-7, + "supports_prompt_caching": true }, "azure_ai/kimi-k2.6": { "deprecation_date": "2027-04-16", @@ -7034,7 +7316,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 0.000004, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-kimi-k2-6-in-microsoft-foundry/4513125", + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", "supported_modalities": [ "text", "image" @@ -7045,6 +7327,32 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, + "supports_vision": true, + "cache_read_input_token_cost": 1.6e-7, + "supports_prompt_caching": true + }, + "azure_ai/kimi-k2.7-code": { + "cache_read_input_token_cost": 1.9e-7, + "deprecation_date": "2026-10-03", + "input_cost_per_token": 9.5e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, "supports_vision": true }, "azure_ai/ministral-3b": { @@ -7227,6 +7535,29 @@ "mode": "chat", "output_cost_per_token": 0.00000315 }, + "baseten/zai-org/GLM-5.3": { + "cache_read_input_token_cost": 1.4e-7, + "input_cost_per_token": 0.0000014, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "source": "https://www.baseten.co/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock/*/1-month-commitment/cohere.command-light-text-v14": { "input_cost_per_second": 0.001902, "litellm_provider": "bedrock", @@ -8524,6 +8855,39 @@ "cache_read_input_token_cost": 3e-8, "cache_creation_input_token_cost": 3.75e-7 }, + "bedrock/us-gov-east-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 0.000015, + "cache_creation_input_token_cost_above_1hr": 0.000024, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000012, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00006, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.0000015, "cache_creation_input_token_cost_above_1hr": 0.0000024, @@ -8550,6 +8914,69 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 4096 }, + "bedrock/us-gov-east-1/anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "bedrock/us-gov-east-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost_above_1hr": 0.0000072, @@ -8575,6 +9002,38 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "bedrock/us-gov-east-1/anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.000003, + "cache_creation_input_token_cost_above_1hr": 0.0000048, + "cache_read_input_token_cost": 2.4e-7, + "input_cost_per_token": 0.0000024, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000012, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost_above_1hr": 0.0000072, @@ -8620,6 +9079,107 @@ "output_cost_per_token": 0.00000265, "supports_pdf_input": true }, + "bedrock/us-gov-east-1/nvidia.nemotron-nano-12b-v2": { + "input_cost_per_token": 2.4e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_system_messages": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.88e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-7, + "supports_system_messages": true + }, + "bedrock/us-gov-east-1/nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.8e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 8.4e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.6e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-west-1/amazon.nova-lite-v1:0": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.88e-7, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/amazon.nova-micro-v1:0": { + "input_cost_per_token": 4.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.68e-7, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, "bedrock/us-gov-west-1/amazon.nova-pro-v1:0": { "input_cost_per_token": 9.6e-7, "litellm_provider": "bedrock", @@ -8716,6 +9276,39 @@ "cache_read_input_token_cost": 3e-8, "cache_creation_input_token_cost": 3.75e-7 }, + "bedrock/us-gov-west-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 0.000015, + "cache_creation_input_token_cost_above_1hr": 0.000024, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000012, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00006, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.0000015, "cache_creation_input_token_cost_above_1hr": 0.0000024, @@ -8742,6 +9335,69 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 4096 }, + "bedrock/us-gov-west-1/anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "bedrock/us-gov-west-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost_above_1hr": 0.0000072, @@ -8767,6 +9423,38 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "bedrock/us-gov-west-1/anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.000003, + "cache_creation_input_token_cost_above_1hr": 0.0000048, + "cache_read_input_token_cost": 2.4e-7, + "input_cost_per_token": 0.0000024, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000012, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost_above_1hr": 0.0000072, @@ -8809,9 +9497,84 @@ "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", - "output_cost_per_token": 0.00000265, + "output_cost_per_token": 6e-7, "supports_pdf_input": true }, + "bedrock/us-gov-west-1/nvidia.nemotron-nano-12b-v2": { + "input_cost_per_token": 2.4e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_system_messages": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.88e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-7, + "supports_system_messages": true + }, + "bedrock/us-gov-west-1/nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.8e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-west-1/openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock/us-gov-west-1/openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 8.4e-8, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.6e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "bedrock/us-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 0.00000265, "litellm_provider": "bedrock", @@ -9125,6 +9888,11 @@ "cache_read_input_token_cost_above_272k_tokens": 4.4e-8, "output_cost_per_token": 0.00000132, "output_cost_per_token_above_272k_tokens": 0.00000198, + "search_context_cost_per_query": { + "search_context_size_high": 0.012, + "search_context_size_low": 0.012, + "search_context_size_medium": 0.012 + }, "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -9147,7 +9915,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": true }, "bedrock_mantle/openai.gpt-5.6-sol": { "input_cost_per_token": 0.0000055, @@ -9158,6 +9927,11 @@ "cache_read_input_token_cost_above_272k_tokens": 0.0000011, "output_cost_per_token": 0.000033, "output_cost_per_token_above_272k_tokens": 0.0000495, + "search_context_cost_per_query": { + "search_context_size_high": 0.012, + "search_context_size_low": 0.012, + "search_context_size_medium": 0.012 + }, "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -9180,7 +9954,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": true }, "bedrock_mantle/openai.gpt-5.6-terra": { "input_cost_per_token": 0.0000022, @@ -9191,6 +9966,11 @@ "cache_read_input_token_cost_above_272k_tokens": 4.4e-7, "output_cost_per_token": 0.0000132, "output_cost_per_token_above_272k_tokens": 0.0000198, + "search_context_cost_per_query": { + "search_context_size_high": 0.012, + "search_context_size_low": 0.012, + "search_context_size_medium": 0.012 + }, "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -9213,7 +9993,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": true }, "bedrock_mantle/openai.gpt-oss-120b": { "input_cost_per_token": 1.5e-7, @@ -9283,6 +10064,264 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 7.2e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-8, + "output_cost_per_token": 3.6e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-east-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 0.00000264, + "output_cost_per_token": 0.00000792, + "cache_read_input_token_cost": 6.6e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-26b-a4b": { + "input_cost_per_token": 1.56e-7, + "output_cost_per_token": 4.8e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-31b": { + "input_cost_per_token": 1.68e-7, + "output_cost_per_token": 4.8e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-e2b": { + "input_cost_per_token": 4.8e-8, + "output_cost_per_token": 9.6e-8, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-luna": { + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token": 2.64e-7, + "input_cost_per_token_above_272k_tokens": 5.28e-7, + "cache_creation_input_token_cost": 3.3e-7, + "cache_creation_input_token_cost_above_272k_tokens": 6.6e-7, + "cache_read_input_token_cost": 2.64e-8, + "cache_read_input_token_cost_above_272k_tokens": 5.28e-8, + "output_cost_per_token": 0.000001584, + "output_cost_per_token_above_272k_tokens": 0.000002376 + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token": 0.00000264, + "input_cost_per_token_above_272k_tokens": 0.00000528, + "cache_creation_input_token_cost": 0.0000033, + "cache_creation_input_token_cost_above_272k_tokens": 0.0000066, + "cache_read_input_token_cost": 2.64e-7, + "cache_read_input_token_cost_above_272k_tokens": 5.28e-7, + "output_cost_per_token": 0.00001584, + "output_cost_per_token_above_272k_tokens": 0.00002376 + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 7.2e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-8, + "output_cost_per_token": 3.6e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-west-1/xai.grok-4.3": { + "use_openai_responses_path": true, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "source": "https://aws.amazon.com/bedrock/pricing/", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": 2.4e-7 + }, + "bedrock_mantle/us-gov-west-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 0.00000264, + "output_cost_per_token": 0.00000792, + "cache_read_input_token_cost": 6.6e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock_mantle/xai.grok-4.3": { "use_openai_responses_path": true, "input_cost_per_token": 0.00000125, @@ -9841,6 +10880,47 @@ "us": 1.1 } }, + "claude-mythos-5-1": { + "deprecation_date": "2027-09-01", + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 2.5e-7, + "input_cost_per_token": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true, + "prompt_cache_min_tokens": 512, + "supports_native_structured_output": true, + "source": "https://platform.claude.com/docs/en/models/mythos-5-1/overview" + }, "claude-mythos-preview": { "cache_creation_input_token_cost": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00002, @@ -12013,6 +13093,7 @@ "databricks/databricks-claude-3-7-sonnet": { "cache_creation_input_token_cost": 0.00000374997, "cache_read_input_token_cost": 3.0002e-7, + "deprecation_date": "2026-04-12", "input_cost_per_token": 0.0000029999900000000002, "input_dbu_cost_per_token": 0.000042857, "litellm_provider": "databricks", @@ -12060,6 +13141,35 @@ "supports_vision": false, "thinking_always_on": true }, + "databricks/databricks-claude-fable-5-1": { + "cache_creation_input_token_cost": 0.00001250004, + "cache_read_input_token_cost": 2.5004e-7, + "input_cost_per_token": 0.00001000006, + "input_dbu_cost_per_token": 0.000142858, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00005000002, + "output_dbu_cost_per_token": 0.000714286, + "prompt_cache_min_tokens": 512, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true + }, "databricks/databricks-claude-haiku-4-5": { "cache_creation_input_token_cost": 0.00000124999, "cache_read_input_token_cost": 1.0003e-7, @@ -12260,6 +13370,7 @@ "databricks/databricks-claude-sonnet-4": { "cache_creation_input_token_cost": 0.00000374997, "cache_read_input_token_cost": 3.0002e-7, + "deprecation_date": "2026-10-09", "input_cost_per_token": 0.0000029999900000000002, "input_dbu_cost_per_token": 0.000042857, "litellm_provider": "databricks", @@ -12272,13 +13383,13 @@ "mode": "chat", "output_cost_per_token": 0.000015000020000000002, "output_dbu_cost_per_token": 0.000214286, + "prompt_cache_min_tokens": 1024, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true, - "prompt_cache_min_tokens": 1024 + "supports_tool_choice": true }, "databricks/databricks-claude-sonnet-4-1": { "cache_creation_input_token_cost": 0.00000374997, @@ -12435,6 +13546,7 @@ "databricks/databricks-gemini-2-5-flash": { "cache_creation_input_token_cost": 3.0002e-7, "cache_read_input_token_cost": 3.0002e-8, + "deprecation_date": "2026-10-02", "input_cost_per_token": 3.0001999999999996e-7, "input_dbu_cost_per_token": 0.000004285999999999999, "litellm_provider": "databricks", @@ -12472,6 +13584,27 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "databricks/databricks-gemini-3-1-flash-image": { + "litellm_provider": "databricks", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "metadata": { + "notes": "Databricks DBU rates not yet published for this model; endpoint metadata only." + }, + "mode": "chat", + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_vision": true + }, "databricks/databricks-gemini-3-1-flash-lite": { "cache_creation_input_token_cost": 3.1248e-7, "cache_read_input_token_cost": 3.122e-8, @@ -12512,6 +13645,148 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "databricks/databricks-gemini-3-5-flash": { + "cache_creation_input_token_cost": 0.00000187502, + "cache_read_input_token_cost": 1.8753e-7, + "input_cost_per_token": 0.00000187502, + "input_dbu_cost_per_token": 0.000026786, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00001124998, + "output_dbu_cost_per_token": 0.000160714, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gemini-3-5-flash-lite": { + "cache_creation_input_token_cost": 3.7499e-7, + "cache_read_input_token_cost": 3.752e-8, + "input_cost_per_token": 3.7499e-7, + "input_dbu_cost_per_token": 0.000005357, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000312501, + "output_dbu_cost_per_token": 0.000044643, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gemini-3-6-flash": { + "cache_creation_input_token_cost": 0.00000187502, + "cache_read_input_token_cost": 1.8753e-7, + "input_cost_per_token": 0.00000187502, + "input_dbu_cost_per_token": 0.000026786, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000937503, + "output_dbu_cost_per_token": 0.000133929, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gemini-3-7-flash": { + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Databricks DBU rates not yet published for this model; endpoint metadata only." + }, + "mode": "chat", + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gemini-3-8-flash": { + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Databricks DBU rates not yet published for this model; endpoint metadata only." + }, + "mode": "chat", + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "databricks/databricks-gemini-3-flash": { "cache_creation_input_token_cost": 6.2503e-7, "cache_read_input_token_cost": 6.251e-8, @@ -12552,6 +13827,27 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "databricks/databricks-gemini-3-pro-image": { + "litellm_provider": "databricks", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "metadata": { + "notes": "Databricks DBU rates not yet published for this model; endpoint metadata only." + }, + "mode": "chat", + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_vision": true + }, "databricks/databricks-gemma-3-12b": { "cache_creation_input_token_cost": 1.5001e-7, "cache_read_input_token_cost": 1.5001e-7, @@ -12597,16 +13893,53 @@ "supports_tool_choice": true, "supports_vision": false }, + "databricks/databricks-glm-5-3": { + "cache_creation_input_token_cost": 0.0000014, + "cache_read_input_token_cost": 2.5998e-7, + "input_cost_per_token": 0.0000014, + "input_dbu_cost_per_token": 0.00002, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000439999, + "output_dbu_cost_per_token": 0.000062857, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "thinking_always_on": true + }, "databricks/databricks-glm-5-3-flash": { + "cache_creation_input_token_cost": 1.5001e-7, + "cache_read_input_token_cost": 3.003e-8, + "input_cost_per_token": 1.5001e-7, + "input_dbu_cost_per_token": 0.000002143, "litellm_provider": "databricks", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "metadata": { - "notes": "Databricks has not published pay-per-token DBU rates for this model yet (not on the foundation-model-serving pricing page as of 2026-08-27), so cost fields are omitted until rates are published." + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." }, "mode": "chat", - "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "output_cost_per_token": 5.0001e-7, + "output_dbu_cost_per_token": 0.000007143, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supported_modalities": [ "text", "image" @@ -12618,7 +13951,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "thinking_always_on": true }, "databricks/databricks-gpt-5": { "cache_creation_input_token_cost": 0.00000124999, @@ -12636,7 +13970,9 @@ "output_cost_per_token": 0.000009999990000000002, "output_dbu_cost_per_token": 0.000142857, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true }, "databricks/databricks-gpt-5-1": { "cache_creation_input_token_cost": 0.00000124999, @@ -12654,11 +13990,14 @@ "output_cost_per_token": 0.000009999990000000002, "output_dbu_cost_per_token": 0.000142857, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true }, "databricks/databricks-gpt-5-1-codex-max": { "cache_creation_input_token_cost": 0.00000124999, "cache_read_input_token_cost": 1.2502e-7, + "deprecation_date": "2026-07-16", "input_cost_per_token": 0.00000124999, "input_dbu_cost_per_token": 0.000017857, "litellm_provider": "databricks", @@ -12677,6 +14016,7 @@ "databricks/databricks-gpt-5-1-codex-mini": { "cache_creation_input_token_cost": 2.4997e-7, "cache_read_input_token_cost": 2.499e-8, + "deprecation_date": "2026-07-16", "input_cost_per_token": 2.4997e-7, "input_dbu_cost_per_token": 0.000003571, "litellm_provider": "databricks", @@ -12708,29 +14048,14 @@ "output_cost_per_token": 0.000014, "output_dbu_cost_per_token": 0.0002, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true }, "databricks/databricks-gpt-5-2-codex": { "cache_creation_input_token_cost": 0.00000175, "cache_read_input_token_cost": 1.75e-7, - "input_cost_per_token": 0.00000175, - "input_dbu_cost_per_token": 0.000025, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 0.000014, - "output_dbu_cost_per_token": 0.0002, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, - "databricks/databricks-gpt-5-3-codex": { - "cache_creation_input_token_cost": 0.00000175, - "cache_read_input_token_cost": 1.75e-7, + "deprecation_date": "2026-07-16", "input_cost_per_token": 0.00000175, "input_dbu_cost_per_token": 0.000025, "litellm_provider": "databricks", @@ -12752,7 +14077,7 @@ "input_cost_per_token": 0.00000249998, "input_dbu_cost_per_token": 0.000035714, "litellm_provider": "databricks", - "max_input_tokens": 272000, + "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "metadata": { @@ -12762,7 +14087,18 @@ "output_cost_per_token": 0.000015000020000000002, "output_dbu_cost_per_token": 0.000214286, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "databricks/databricks-gpt-5-4-mini": { "cache_creation_input_token_cost": 7.4998e-7, @@ -12780,7 +14116,18 @@ "output_cost_per_token": 0.00000450002, "output_dbu_cost_per_token": 0.000064286, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "databricks/databricks-gpt-5-4-nano": { "cache_creation_input_token_cost": 1.9999e-7, @@ -12798,7 +14145,105 @@ "output_cost_per_token": 0.00000124999, "output_dbu_cost_per_token": 0.000017857, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gpt-5-6-luna": { + "cache_creation_input_token_cost": 0.00000124999, + "cache_read_input_token_cost": 1.0003e-7, + "input_cost_per_token": 0.00000100002, + "input_dbu_cost_per_token": 0.000014286, + "litellm_provider": "databricks", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000599998, + "output_dbu_cost_per_token": 0.000085714, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gpt-5-6-sol": { + "cache_creation_input_token_cost": 0.00000500003, + "cache_read_input_token_cost": 3.9998e-7, + "input_cost_per_token": 0.00000400001, + "input_dbu_cost_per_token": 0.000057143, + "litellm_provider": "databricks", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields. Rates reflect OpenAI's promotional pricing in effect through November 21, 2026; afterwards input, cache and Batch rates are 25% higher and output rates 50% higher." + }, + "mode": "chat", + "output_cost_per_token": 0.00001999998, + "output_dbu_cost_per_token": 0.000285714, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "databricks/databricks-gpt-5-6-terra": { + "cache_creation_input_token_cost": 0.00000312501, + "cache_read_input_token_cost": 2.4997e-7, + "input_cost_per_token": 0.00000249998, + "input_dbu_cost_per_token": 0.000035714, + "litellm_provider": "databricks", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00001500002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "databricks/databricks-gpt-5-mini": { "cache_creation_input_token_cost": 2.4997e-7, @@ -12816,7 +14261,9 @@ "output_cost_per_token": 0.0000019999700000000004, "output_dbu_cost_per_token": 0.000028571, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true }, "databricks/databricks-gpt-5-nano": { "cache_creation_input_token_cost": 4.998e-8, @@ -12834,7 +14281,9 @@ "output_cost_per_token": 3.9998000000000007e-7, "output_dbu_cost_per_token": 0.000005714000000000001, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true }, "databricks/databricks-gpt-oss-120b": { "cache_creation_input_token_cost": 1.5001e-7, @@ -12870,6 +14319,60 @@ "output_dbu_cost_per_token": 0.000004285999999999999, "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, + "databricks/databricks-grok-4-6": { + "cache_creation_input_token_cost": 0.00000249998, + "cache_read_input_token_cost": 6.2503e-7, + "input_cost_per_token": 0.00000249998, + "input_dbu_cost_per_token": 0.000035714, + "litellm_provider": "databricks", + "max_input_tokens": 500000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000750001, + "output_dbu_cost_per_token": 0.000107143, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "databricks/databricks-inkling": { + "cache_creation_input_token_cost": 0.00000100002, + "cache_read_input_token_cost": 1.7003e-7, + "input_cost_per_token": 0.00000100002, + "input_dbu_cost_per_token": 0.000014286, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000404999, + "output_dbu_cost_per_token": 0.000057857, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true + }, "databricks/databricks-kimi-k3": { "cache_creation_input_token_cost": 0.00000299999, "cache_read_input_token_cost": 3.0002e-7, @@ -12902,6 +14405,7 @@ "databricks/databricks-llama-2-70b-chat": { "cache_creation_input_token_cost": 5.0001e-7, "cache_read_input_token_cost": 5.0001e-7, + "deprecation_date": "2024-10-30", "input_cost_per_token": 5.0001e-7, "input_dbu_cost_per_token": 0.000007143, "litellm_provider": "databricks", @@ -12938,6 +14442,7 @@ "databricks/databricks-meta-llama-3-1-405b-instruct": { "cache_creation_input_token_cost": 0.00000500003, "cache_read_input_token_cost": 0.00000500003, + "deprecation_date": "2026-02-15", "input_cost_per_token": 0.00000500003, "input_dbu_cost_per_token": 0.000071429, "litellm_provider": "databricks", @@ -12991,6 +14496,7 @@ "databricks/databricks-meta-llama-3-70b-instruct": { "cache_creation_input_token_cost": 0.00000100002, "cache_read_input_token_cost": 0.00000100002, + "deprecation_date": "2024-07-23", "input_cost_per_token": 0.00000100002, "input_dbu_cost_per_token": 0.000014286, "litellm_provider": "databricks", @@ -13009,6 +14515,7 @@ "databricks/databricks-mixtral-8x7b-instruct": { "cache_creation_input_token_cost": 5.0001e-7, "cache_read_input_token_cost": 5.0001e-7, + "deprecation_date": "2025-04-30", "input_cost_per_token": 5.0001e-7, "input_dbu_cost_per_token": 0.000007143, "litellm_provider": "databricks", @@ -13027,6 +14534,7 @@ "databricks/databricks-mpt-30b-instruct": { "cache_creation_input_token_cost": 0.00000100002, "cache_read_input_token_cost": 0.00000100002, + "deprecation_date": "2024-08-30", "input_cost_per_token": 0.00000100002, "input_dbu_cost_per_token": 0.000014286, "litellm_provider": "databricks", @@ -13045,6 +14553,7 @@ "databricks/databricks-mpt-7b-instruct": { "cache_creation_input_token_cost": 5.0001e-7, "cache_read_input_token_cost": 5.0001e-7, + "deprecation_date": "2024-08-30", "input_cost_per_token": 5.0001e-7, "input_dbu_cost_per_token": 0.000007143, "litellm_provider": "databricks", @@ -13060,6 +14569,57 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, + "databricks/databricks-qwen3-next-80b-a3b-instruct": { + "cache_creation_input_token_cost": 1.5001e-7, + "cache_read_input_token_cost": 1.5001e-7, + "input_cost_per_token": 1.5001e-7, + "input_dbu_cost_per_token": 0.000002143, + "litellm_provider": "databricks", + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000120001, + "output_dbu_cost_per_token": 0.000017143, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "databricks/databricks-qwen35-122b-a10b": { + "cache_creation_input_token_cost": 2.2001e-7, + "cache_read_input_token_cost": 2.2001e-7, + "input_cost_per_token": 2.2001e-7, + "input_dbu_cost_per_token": 0.000003143, + "litellm_provider": "databricks", + "max_input_tokens": 262144, + "max_output_tokens": 25000, + "max_tokens": 25000, + "metadata": { + "notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields." + }, + "mode": "chat", + "output_cost_per_token": 0.00000220003, + "output_dbu_cost_per_token": 0.000031429, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false, + "thinking_always_on": true + }, "daybreak-blue-latest": { "cache_creation_input_token_cost": 0.000005, "cache_creation_input_token_cost_above_272k_tokens": 0.00001, @@ -13096,7 +14656,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "source": "https://developers.openai.com/api/docs/models/daybreak-blue-latest", + "source": "https://developers.openai.com/api/docs/models/gpt-daybreak-blue-latest", "supports_parallel_function_calling": true }, "daybreak-red-latest": { @@ -13134,7 +14694,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "source": "https://developers.openai.com/api/docs/models/daybreak-red-latest", + "source": "https://developers.openai.com/api/docs/models/gpt-daybreak-red-latest", "supports_computer_use": true, "supports_parallel_function_calling": true }, @@ -16561,6 +18121,19 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-vision-exp": { + "cache_read_input_token_cost": 7e-9, + "input_cost_per_token": 2.2e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6.6e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { "cache_read_input_token_cost": 1.45e-7, "input_cost_per_token": 0.00000174, @@ -16892,6 +18465,20 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 5e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "cache_read_input_token_cost": 1.5e-8, "input_cost_per_token": 1.5e-7, @@ -16951,6 +18538,20 @@ "litellm_provider": "fireworks_ai", "mode": "chat" }, + "fireworks_ai/accounts/fireworks/models/inkling": { + "cache_read_input_token_cost": 1.7e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 0.00000405, + "source": "https://fireworks.ai/models/fireworks/inkling", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/internvl3-38b": { "max_tokens": 16384, "max_input_tokens": 16384, @@ -18799,6 +20400,19 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/deepseek-v4-flash-vision-exp": { + "cache_read_input_token_cost": 7e-9, + "input_cost_per_token": 2.2e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6.6e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/deepseek-v4-pro": { "cache_read_input_token_cost": 1.45e-7, "input_cost_per_token": 0.00000174, @@ -19887,16 +21501,15 @@ "supports_image_size": false }, "gemini-2.5-flash-native-audio-latest": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -19913,16 +21526,15 @@ "gemini_native_audio": true }, "gemini-2.5-flash-native-audio-preview-09-2025": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -19939,16 +21551,15 @@ "gemini_native_audio": true }, "gemini-2.5-flash-native-audio-preview-12-2025": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -20874,6 +22485,63 @@ "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014 }, + "gemini-3.8-flash": { + "prompt_cache_min_tokens": 4096, + "cache_read_input_token_cost": 7.5e-8, + "cache_read_input_token_cost_flex": 3.75e-8, + "input_cost_per_token": 7.5e-7, + "input_cost_per_token_batches": 3.75e-7, + "input_cost_per_token_flex": 3.75e-7, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_reasoning_token": 0.00000375, + "output_cost_per_token": 0.00000375, + "output_cost_per_token_batches": 0.000001875, + "output_cost_per_token_flex": 0.000001875, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_audio_input": true, + "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, + "supports_native_streaming": true, + "input_cost_per_token_priority": 0.00000135, + "output_cost_per_token_priority": 0.00000675, + "cache_read_input_token_cost_priority": 1.35e-7, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 + }, "gemini-exp-1206": { "cache_read_input_token_cost": 3e-8, "input_cost_per_audio_token": 0.000001, @@ -21594,16 +23262,15 @@ "supports_image_size": false }, "gemini/gemini-2.5-flash-native-audio-latest": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -21622,16 +23289,15 @@ "gemini_native_audio": true }, "gemini/gemini-2.5-flash-native-audio-preview-09-2025": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -21650,16 +23316,15 @@ "gemini_native_audio": true }, "gemini/gemini-2.5-flash-native-audio-preview-12-2025": { - "input_cost_per_audio_token": 0.000003, - "input_cost_per_token": 5e-7, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_audio_token": 0.000012, - "output_cost_per_token": 0.000002, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -22510,29 +24175,66 @@ "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014 }, - "gemini/gemini-3.5-live-translate-preview": { - "input_cost_per_audio_token": 0.0000035, - "input_cost_per_token": 0.0000035, + "gemini/gemini-3.6-flash": { + "prompt_cache_min_tokens": 4096, + "cache_read_input_token_cost": 7.5e-8, + "cache_read_input_token_cost_flex": 3.75e-8, + "input_cost_per_token": 7.5e-7, + "input_cost_per_token_batches": 3.75e-7, + "input_cost_per_token_flex": 3.75e-7, "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_audio_token": 0.000021, - "output_cost_per_token": 0.000021, - "rpm": 10, + "output_cost_per_reasoning_token": 0.00000375, + "output_cost_per_token": 0.00000375, + "output_cost_per_token_batches": 0.000001875, + "output_cost_per_token_flex": 0.000001875, + "rpm": 2000, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/v1/realtime" + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" ], "supported_modalities": [ - "audio" + "text", + "image", + "audio", + "video" ], "supported_output_modalities": [ - "audio" + "text" ], + "supports_audio_output": false, "supports_audio_input": true, - "supports_audio_output": true, - "tpm": 250000 + "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, + "supports_native_streaming": true, + "tpm": 800000, + "input_cost_per_token_priority": 0.00000135, + "output_cost_per_token_priority": 0.00000675, + "cache_read_input_token_cost_priority": 1.35e-7, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, - "gemini/gemini-3.6-flash": { + "gemini/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-8, "cache_read_input_token_cost_flex": 3.75e-8, @@ -22591,7 +24293,7 @@ "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014 }, - "gemini/gemini-3.7-flash": { + "gemini/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-8, "cache_read_input_token_cost_flex": 3.75e-8, @@ -23236,8 +24938,59 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": false, + "supports_web_search": false, + "output_cost_per_image": 0.08 + }, + "gemini/lyria-3.5-clip-preview": { + "input_cost_per_token": 0, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.04, + "output_cost_per_token": 0, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": false, "supports_web_search": false }, + "gemini/lyria-3.5-pro-preview": { + "input_cost_per_token": 0, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": false, + "supports_web_search": false, + "output_cost_per_image": 0.08 + }, "gemini/nano-banana-pro-preview": { "input_cost_per_image": 0.0011, "input_cost_per_token": 0.000002, @@ -25909,7 +27662,10 @@ "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "input_cost_per_token_above_272k_tokens_flex": 0.0000025, + "output_cost_per_token_above_272k_tokens_flex": 0.00001125, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-7 }, "gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-7, @@ -25958,7 +27714,10 @@ "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "input_cost_per_token_above_272k_tokens_flex": 0.0000025, + "output_cost_per_token_above_272k_tokens_flex": 0.00001125, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-7 }, "gpt-5.4-mini": { "cache_read_input_token_cost": 7.5e-8, @@ -26166,12 +27925,12 @@ "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, "cache_read_input_token_cost_flex": 2.5e-7, - "cache_read_input_token_cost_priority": 0.000001, + "cache_read_input_token_cost_priority": 0.00000125, "input_cost_per_token": 0.000005, "input_cost_per_token_above_272k_tokens": 0.00001, "input_cost_per_token_flex": 0.0000025, "input_cost_per_token_batches": 0.0000025, - "input_cost_per_token_priority": 0.00001, + "input_cost_per_token_priority": 0.0000125, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -26181,7 +27940,7 @@ "output_cost_per_token_above_272k_tokens": 0.000045, "output_cost_per_token_flex": 0.000015, "output_cost_per_token_batches": 0.000015, - "output_cost_per_token_priority": 0.00006, + "output_cost_per_token_priority": 0.000075, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, "search_context_cost_per_query": { @@ -26214,18 +27973,21 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "input_cost_per_token_above_272k_tokens_flex": 0.000005, + "output_cost_per_token_above_272k_tokens_flex": 0.0000225, + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-7 }, "gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, "cache_read_input_token_cost_flex": 2.5e-7, - "cache_read_input_token_cost_priority": 0.000001, + "cache_read_input_token_cost_priority": 0.00000125, "input_cost_per_token": 0.000005, "input_cost_per_token_above_272k_tokens": 0.00001, "input_cost_per_token_flex": 0.0000025, "input_cost_per_token_batches": 0.0000025, - "input_cost_per_token_priority": 0.00001, + "input_cost_per_token_priority": 0.0000125, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -26235,7 +27997,7 @@ "output_cost_per_token_above_272k_tokens": 0.000045, "output_cost_per_token_flex": 0.000015, "output_cost_per_token_batches": 0.000015, - "output_cost_per_token_priority": 0.00006, + "output_cost_per_token_priority": 0.000075, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, "search_context_cost_per_query": { @@ -26268,22 +28030,28 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "input_cost_per_token_above_272k_tokens_flex": 0.000005, + "output_cost_per_token_above_272k_tokens_flex": 0.0000225, + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-7 }, "gpt-5.6": { "cache_creation_input_token_cost": 0.000005, "cache_creation_input_token_cost_above_272k_tokens": 0.00001, "cache_creation_input_token_cost_above_272k_tokens_flex": 0.000005, + "cache_creation_input_token_cost_above_272k_tokens_priority": 0.00002, "cache_creation_input_token_cost_flex": 0.0000025, "cache_creation_input_token_cost_priority": 0.00001, "cache_read_input_token_cost": 4e-7, "cache_read_input_token_cost_above_272k_tokens": 8e-7, "cache_read_input_token_cost_above_272k_tokens_flex": 4e-7, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.0000016, "cache_read_input_token_cost_flex": 2e-7, "cache_read_input_token_cost_priority": 8e-7, "input_cost_per_token": 0.000004, "input_cost_per_token_above_272k_tokens": 0.000008, "input_cost_per_token_above_272k_tokens_flex": 0.000004, + "input_cost_per_token_above_272k_tokens_priority": 0.000016, "input_cost_per_token_batches": 0.000002, "input_cost_per_token_flex": 0.000002, "input_cost_per_token_priority": 0.000008, @@ -26295,6 +28063,7 @@ "output_cost_per_token": 0.00002, "output_cost_per_token_above_272k_tokens": 0.00003, "output_cost_per_token_above_272k_tokens_flex": 0.000015, + "output_cost_per_token_above_272k_tokens_priority": 0.00006, "output_cost_per_token_batches": 0.00001, "output_cost_per_token_flex": 0.00001, "output_cost_per_token_priority": 0.00004, @@ -26376,16 +28145,19 @@ "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_above_272k_tokens_priority": 0.000001, "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_above_272k_tokens_priority": 8e-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_above_272k_tokens_priority": 8e-7, "input_cost_per_token_batches": 1e-7, "input_cost_per_token_flex": 1e-7, "input_cost_per_token_priority": 4e-7, @@ -26397,6 +28169,7 @@ "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_above_272k_tokens_priority": 0.0000036, "output_cost_per_token_batches": 6e-7, "output_cost_per_token_flex": 6e-7, "output_cost_per_token_priority": 0.0000024, @@ -26439,16 +28212,19 @@ "cache_creation_input_token_cost": 0.000005, "cache_creation_input_token_cost_above_272k_tokens": 0.00001, "cache_creation_input_token_cost_above_272k_tokens_flex": 0.000005, + "cache_creation_input_token_cost_above_272k_tokens_priority": 0.00002, "cache_creation_input_token_cost_flex": 0.0000025, "cache_creation_input_token_cost_priority": 0.00001, "cache_read_input_token_cost": 4e-7, "cache_read_input_token_cost_above_272k_tokens": 8e-7, "cache_read_input_token_cost_above_272k_tokens_flex": 4e-7, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.0000016, "cache_read_input_token_cost_flex": 2e-7, "cache_read_input_token_cost_priority": 8e-7, "input_cost_per_token": 0.000004, "input_cost_per_token_above_272k_tokens": 0.000008, "input_cost_per_token_above_272k_tokens_flex": 0.000004, + "input_cost_per_token_above_272k_tokens_priority": 0.000016, "input_cost_per_token_batches": 0.000002, "input_cost_per_token_flex": 0.000002, "input_cost_per_token_priority": 0.000008, @@ -26460,6 +28236,7 @@ "output_cost_per_token": 0.00002, "output_cost_per_token_above_272k_tokens": 0.00003, "output_cost_per_token_above_272k_tokens_flex": 0.000015, + "output_cost_per_token_above_272k_tokens_priority": 0.00006, "output_cost_per_token_batches": 0.00001, "output_cost_per_token_flex": 0.00001, "output_cost_per_token_priority": 0.00004, @@ -26503,16 +28280,19 @@ "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_above_272k_tokens_priority": 0.00001, "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_above_272k_tokens_priority": 8e-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_above_272k_tokens_priority": 0.000008, "input_cost_per_token_batches": 0.000001, "input_cost_per_token_flex": 0.000001, "input_cost_per_token_priority": 0.000004, @@ -26524,6 +28304,7 @@ "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_above_272k_tokens_priority": 0.000036, "output_cost_per_token_batches": 0.000006, "output_cost_per_token_flex": 0.000006, "output_cost_per_token_priority": 0.000024, @@ -26562,6 +28343,75 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "gpt-6-astra": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens": 0.000025, + "cache_creation_input_token_cost_above_272k_tokens_flex": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens_priority": 0.00005, + "cache_creation_input_token_cost_flex": 0.00000625, + "cache_creation_input_token_cost_priority": 0.000025, + "cache_read_input_token_cost": 0.000001, + "cache_read_input_token_cost_above_272k_tokens": 0.000002, + "cache_read_input_token_cost_above_272k_tokens_flex": 0.000001, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.000004, + "cache_read_input_token_cost_flex": 5e-7, + "cache_read_input_token_cost_priority": 0.000002, + "input_cost_per_token": 0.00001, + "input_cost_per_token_above_272k_tokens": 0.00002, + "input_cost_per_token_above_272k_tokens_flex": 0.00001, + "input_cost_per_token_above_272k_tokens_priority": 0.00004, + "input_cost_per_token_batches": 0.000005, + "input_cost_per_token_flex": 0.000005, + "input_cost_per_token_priority": 0.00002, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "output_cost_per_token_above_272k_tokens": 0.000075, + "output_cost_per_token_above_272k_tokens_flex": 0.0000375, + "output_cost_per_token_above_272k_tokens_priority": 0.00015, + "output_cost_per_token_batches": 0.000025, + "output_cost_per_token_flex": 0.000025, + "output_cost_per_token_priority": 0.0001, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "gpt-audio": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 0.000032, @@ -28765,6 +30615,88 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "meta/muse-spark-1.3": { + "cache_read_input_token_cost": 1.5e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.00000425, + "search_context_cost_per_query": { + "search_context_size_high": 0.0025, + "search_context_size_low": 0.0025, + "search_context_size_medium": 0.0025 + }, + "source": "https://ai.developer.meta.com/docs/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "meta/muse-spark-1.3-contributor": { + "cache_read_input_token_cost": 2e-9, + "input_cost_per_token": 1e-7, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.0025, + "search_context_size_low": 0.0025, + "search_context_size_medium": 0.0025 + }, + "source": "https://ai.developer.meta.com/docs/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, @@ -29373,19 +31305,21 @@ "supports_tool_choice": true }, "mistral/magistral-medium-latest": { - "input_cost_per_token": 0.000002, + "cache_read_input_token_cost": 1.5e-7, + "input_cost_per_token": 0.0000015, "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 0.000005, - "source": "https://mistral.ai/news/magistral", + "output_cost_per_token": 0.0000075, + "source": "https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/magistral-small-1-2-2509": { "deprecation_date": "2026-07-31", @@ -29420,19 +31354,21 @@ "supports_tool_choice": true }, "mistral/magistral-small-latest": { - "input_cost_per_token": 5e-7, + "cache_read_input_token_cost": 1.5e-8, + "input_cost_per_token": 1.5e-7, "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 0.0000015, - "source": "https://mistral.ai/pricing#api-pricing", + "output_cost_per_token": 6e-7, + "source": "https://docs.mistral.ai/models/model-cards/mistral-small-4-0-26-03", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/ministral-14b-2512": { "input_cost_per_token": 2e-7, @@ -29447,7 +31383,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2e-8 }, "mistral/ministral-14b-latest": { "input_cost_per_token": 2e-7, @@ -29462,7 +31399,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2e-8 }, "mistral/ministral-3-14b-2512": { "cache_read_input_token_cost": 2e-8, @@ -29525,7 +31463,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1e-8 }, "mistral/ministral-3b-latest": { "input_cost_per_token": 1e-7, @@ -29540,7 +31479,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1e-8 }, "mistral/ministral-8b-2512": { "cache_read_input_token_cost": 1.5e-8, @@ -29709,16 +31649,21 @@ "supports_vision": true }, "mistral/mistral-medium": { - "input_cost_per_token": 0.0000027, + "cache_read_input_token_cost": 1.5e-7, + "input_cost_per_token": 0.0000015, "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 0.0000081, + "output_cost_per_token": 0.0000075, + "source": "https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04", "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-medium-2312": { "deprecation_date": "2025-06-16", @@ -30572,6 +32517,31 @@ "supports_tool_choice": false, "supports_vision": false }, + "nebius/MiniMaxAI/MiniMax-M2.5": { + "max_tokens": 196608, + "max_input_tokens": 196608, + "max_output_tokens": 196608, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/MiniMaxAI%2FMiniMax-M2.5" + }, + "nebius/MiniMaxAI/MiniMax-M3": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/MiniMaxAI%2FMiniMax-M3" + }, "nebius/NousResearch/Hermes-3-Llama-3.1-405B": { "max_tokens": 128000, "max_input_tokens": 128000, @@ -30583,6 +32553,30 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/NousResearch/Hermes-4-405B": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/NousResearch%2FHermes-4-405B" + }, + "nebius/NousResearch/Hermes-4-70B": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/NousResearch%2FHermes-4-70B" + }, "nebius/Qwen/QwQ-32B": { "max_tokens": 32768, "max_input_tokens": 32768, @@ -30652,16 +32646,16 @@ "source": "https://nebius.com/prices" }, "nebius/Qwen/Qwen2.5-VL-72B-Instruct": { - "max_tokens": 131072, - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 4e-7, + "max_tokens": 32000, + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 7.5e-7, "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, "supports_vision": true, - "source": "https://nebius.com/prices" + "source": "https://tokenfactory.nebius.com/models/catalog/image2text/Qwen%2FQwen2.5-VL-72B-Instruct" }, "nebius/Qwen/Qwen3-14B": { "max_tokens": 32768, @@ -30685,6 +32679,17 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-235B-A22B-Instruct-2507" + }, "nebius/Qwen/Qwen3-30B-A3B": { "max_tokens": 32768, "max_input_tokens": 32768, @@ -30696,16 +32701,27 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/Qwen/Qwen3-30B-A3B-Instruct-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-30B-A3B-Instruct-2507" + }, "nebius/Qwen/Qwen3-32B": { - "max_tokens": 32768, - "max_input_tokens": 32768, - "max_output_tokens": 32768, + "max_tokens": 40960, + "max_input_tokens": 40960, + "max_output_tokens": 40960, "input_cost_per_token": 1e-7, "output_cost_per_token": 3e-7, "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, - "source": "https://nebius.com/prices" + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-32B" }, "nebius/Qwen/Qwen3-4B": { "max_tokens": 32768, @@ -30718,6 +32734,30 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/Qwen/Qwen3-Next-80B-A3B-Thinking": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-Next-80B-A3B-Thinking" + }, + "nebius/Qwen/Qwen3.5-397B-A17B": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000036, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3.5-397B-A17B" + }, "nebius/deepseek-ai/DeepSeek-R1": { "max_tokens": 128000, "max_input_tokens": 128000, @@ -30775,22 +32815,58 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/deepseek-ai/DeepSeek-V4-Flash": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 2.8e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash" + }, + "nebius/deepseek-ai/DeepSeek-V4-Flash-0731": { + "max_tokens": 1024000, + "max_input_tokens": 1024000, + "max_output_tokens": 1024000, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 2.8e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash-0731" + }, + "nebius/deepseek-ai/DeepSeek-V4-Pro": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.0000035, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Pro" + }, "nebius/google/gemma-3-27b-it": { - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2e-7, + "max_tokens": 110000, + "max_input_tokens": 110000, + "max_output_tokens": 110000, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, "supports_vision": true, - "source": "https://nebius.com/prices" + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/google%2Fgemma-3-27b-it" }, "nebius/meta-llama/Llama-3.3-70B-Instruct": { - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, "input_cost_per_token": 1.3e-7, "output_cost_per_token": 4e-7, "litellm_provider": "nebius", @@ -30852,6 +32928,58 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/moonshotai/Kimi-K2.6": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 9.5e-7, + "output_cost_per_token": 0.000004, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K2.6" + }, + "nebius/moonshotai/Kimi-K2.7-Code": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 9.5e-7, + "output_cost_per_token": 0.000004, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/moonshotai%2FKimi-K2.7-Code" + }, + "nebius/moonshotai/Kimi-K3": { + "max_tokens": 1024000, + "max_input_tokens": 1024000, + "max_output_tokens": 1024000, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K3" + }, + "nebius/nvidia/Cosmos3-Super-Reasoner": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/image2text/nvidia%2FCosmos3-Super-Reasoner" + }, "nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1": { "max_tokens": 128000, "max_input_tokens": 128000, @@ -30874,6 +33002,137 @@ "supports_function_calling": true, "source": "https://nebius.com/prices" }, + "nebius/nvidia/Llama-3_1-Nemotron-Ultra-253B-v1": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000018, + "litellm_provider": "nebius", + "mode": "chat", + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FLlama-3_1-Nemotron-Ultra-253B-v1" + }, + "nebius/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNVIDIA-Nemotron-3-Nano-30B-A3B" + }, + "nebius/nvidia/Nemotron-3-Nano-Omni": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3-Nano-Omni" + }, + "nebius/nvidia/Nemotron-3-Ultra-550b-a55b": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3-Ultra-550b-a55b" + }, + "nebius/nvidia/Nemotron-3_5-Lightning": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3_5-Lightning" + }, + "nebius/nvidia/nemotron-3-super-120b-a12b": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2Fnemotron-3-super-120b-a12b" + }, + "nebius/openai/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/openai%2Fgpt-oss-120b" + }, + "nebius/openbmb/MiniCPM-V-4_5": { + "max_tokens": 32000, + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "input_cost_per_token": 6.58e-7, + "output_cost_per_token": 0.00000111, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "source": "https://tokenfactory.nebius.com/models/catalog/image2text/openbmb%2FMiniCPM-V-4_5" + }, + "nebius/zai-org/GLM-5.1": { + "max_tokens": 202752, + "max_input_tokens": 202752, + "max_output_tokens": 202752, + "input_cost_per_token": 0.0000014, + "output_cost_per_token": 0.0000044, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.1" + }, + "nebius/zai-org/GLM-5.2": { + "max_tokens": 1048576, + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "input_cost_per_token": 0.0000014, + "output_cost_per_token": 0.0000044, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.2" + }, + "nebius/zai-org/GLM-5.3-Flash": { + "max_tokens": 1024000, + "max_input_tokens": 1024000, + "max_output_tokens": 1024000, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.3-Flash" + }, "novita/Sao10K/L3-8B-Stheno-v3.2": { "litellm_provider": "novita", "mode": "chat", @@ -31889,7 +34148,7 @@ "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, - "supports_vision": true, + "supports_vision": false, "supports_system_messages": true, "supports_response_schema": true, "supports_reasoning": true @@ -31903,7 +34162,7 @@ "max_output_tokens": 32768, "max_tokens": 32768, "supports_tool_choice": true, - "supports_vision": true, + "supports_vision": false, "supports_system_messages": true, "supports_response_schema": true, "supports_reasoning": true @@ -34537,14 +36796,62 @@ "supports_tool_choice": true, "supports_vision": true }, + "openrouter/anthropic/claude-fable-5": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_sampling_params": false, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "source": "https://openrouter.ai/anthropic/claude-fable-5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 0.000001, + "supports_prompt_caching": true, + "cache_creation_input_token_cost": 0.0000125, + "prompt_cache_min_tokens": 512 + }, + "openrouter/anthropic/claude-fable-5.1": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_sampling_params": false, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "source": "https://openrouter.ai/anthropic/claude-fable-5.1", + "supports_function_calling": true, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2.5e-7, + "supports_prompt_caching": true, + "cache_creation_input_token_cost": 0.0000125, + "prompt_cache_min_tokens": 512 + }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 0.00000125, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "openrouter", "max_input_tokens": 200000, - "max_output_tokens": 200000, - "max_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000005, "supports_assistant_prefill": true, @@ -34554,7 +36861,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://openrouter.ai/anthropic/claude-haiku-4.5" }, "openrouter/anthropic/claude-opus-4": { "input_cost_per_image": 0.0048, @@ -34603,8 +36911,8 @@ "input_cost_per_token": 0.000005, "litellm_provider": "openrouter", "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, + "max_output_tokens": 64000, + "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000025, "supports_assistant_prefill": true, @@ -34615,7 +36923,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://openrouter.ai/anthropic/claude-opus-4.5" }, "openrouter/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, @@ -34663,6 +36972,28 @@ "supports_xhigh_reasoning_effort": true, "prompt_cache_min_tokens": 2048 }, + "openrouter/anthropic/claude-opus-4.8": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_sampling_params": false, + "supports_adaptive_thinking": true, + "source": "https://openrouter.ai/anthropic/claude-opus-4.8", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 5e-7, + "supports_prompt_caching": true, + "cache_creation_input_token_cost": 0.00000625 + }, "openrouter/anthropic/claude-opus-5": { "prompt_cache_min_tokens": 512, "supports_adaptive_thinking": true, @@ -34722,9 +37053,9 @@ "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost_above_200k_tokens": 6e-7, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, "supports_assistant_prefill": true, @@ -34734,7 +37065,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/anthropic/claude-sonnet-4.5" }, "openrouter/anthropic/claude-sonnet-4.6": { "supports_adaptive_thinking": true, @@ -34763,6 +37095,28 @@ "supports_vision": true, "prompt_cache_min_tokens": 1024 }, + "openrouter/anthropic/claude-sonnet-5": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_sampling_params": false, + "supports_adaptive_thinking": true, + "source": "https://openrouter.ai/anthropic/claude-sonnet-5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true, + "cache_creation_input_token_cost": 0.0000025 + }, "openrouter/bytedance/ui-tars-1.5-7b": { "input_cost_per_token": 1e-7, "litellm_provider": "openrouter", @@ -34775,24 +37129,24 @@ "supports_tool_choice": true }, "openrouter/deepseek/deepseek-chat": { - "input_cost_per_token": 1.4e-7, + "input_cost_per_token": 3.2e-7, "litellm_provider": "openrouter", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 2.8e-7, + "output_cost_per_token": 8.9e-7, "supports_prompt_caching": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-chat-v3-0324": { - "input_cost_per_token": 1.4e-7, + "input_cost_per_token": 2.5e-7, "litellm_provider": "openrouter", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 2.8e-7, + "output_cost_per_token": 0.000001, "supports_prompt_caching": true, "supports_tool_choice": true }, @@ -34812,14 +37166,14 @@ "supports_tool_choice": true }, "openrouter/deepseek/deepseek-r1": { - "input_cost_per_token": 5.5e-7, + "input_cost_per_token": 7e-7, "input_cost_per_token_cache_hit": 1.4e-7, "litellm_provider": "openrouter", "max_input_tokens": 65336, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.00000219, + "output_cost_per_token": 0.0000025, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -34841,8 +37195,39 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-r1-distill-llama-70b": { + "input_cost_per_token": 8e-7, + "output_cost_per_token": 8e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 7372, + "max_tokens": 7372, + "mode": "chat", + "source": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/deepseek/deepseek-v3.1-terminus": { + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 0.000001, + "cache_read_input_token_cost": 1.35e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/deepseek/deepseek-v3.1-terminus", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, "openrouter/deepseek/deepseek-v3.2": { - "input_cost_per_token": 2.8e-7, + "input_cost_per_token": 2.69e-7, "input_cost_per_token_cache_hit": 2.8e-8, "litellm_provider": "openrouter", "max_input_tokens": 163840, @@ -34857,20 +37242,72 @@ "supports_tool_choice": true }, "openrouter/deepseek/deepseek-v3.2-exp": { - "input_cost_per_token": 2e-7, + "input_cost_per_token": 2.7e-7, "input_cost_per_token_cache_hit": 2e-8, "litellm_provider": "openrouter", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, "mode": "chat", - "output_cost_per_token": 4e-7, + "output_cost_per_token": 4.1e-7, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": false, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-v4-flash": { + "input_cost_per_token": 8.778e-8, + "output_cost_per_token": 1.7556e-7, + "cache_read_input_token_cost": 1.7556e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "source": "https://openrouter.ai/deepseek/deepseek-v4-flash", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/deepseek/deepseek-v4-flash-0731": { + "input_cost_per_token": 6.5e-8, + "output_cost_per_token": 1.8e-7, + "cache_read_input_token_cost": 1.6e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-0731", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_parallel_function_calling": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/deepseek/deepseek-v4-flash-vision-exp": { + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": 7e-9, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-vision-exp", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, "openrouter/deepseek/deepseek-v4-pro": { "input_cost_per_token": 0.00000132, "input_cost_per_token_cache_hit": 4.4e-8, @@ -34925,8 +37362,8 @@ "input_cost_per_token": 3e-7, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_output_tokens": 65535, + "max_tokens": 65535, "mode": "chat", "output_cost_per_token": 0.0000025, "supports_audio_output": true, @@ -34935,15 +37372,56 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_image_size": false + "supports_image_size": false, + "cache_read_input_token_cost": 3e-8, + "supports_prompt_caching": true, + "source": "https://openrouter.ai/google/gemini-2.5-flash" + }, + "openrouter/google/gemini-2.5-flash-image": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "cache_creation_input_token_cost": 8.33333333333333e-8, + "input_cost_per_audio_token": 0.000001, + "output_cost_per_image_token": 0.00003, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-2.5-flash-image", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/google/gemini-2.5-flash-lite": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-2.5-flash-lite", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 1e-8, + "supports_prompt_caching": true }, "openrouter/google/gemini-2.5-pro": { "input_cost_per_audio_token": 7e-7, "input_cost_per_token": 0.00000125, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_output_tokens": 65535, + "max_tokens": 65535, "mode": "chat", "output_cost_per_token": 0.00001, "supports_audio_output": true, @@ -34951,7 +37429,58 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.25e-7, + "supports_prompt_caching": true, + "source": "https://openrouter.ai/google/gemini-2.5-pro" + }, + "openrouter/google/gemini-2.5-pro-preview": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_audio_token": 0.00000125, + "input_cost_per_token_above_200k_tokens": 0.0000025, + "output_cost_per_token_above_200k_tokens": 0.000015, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-2.5-pro-preview", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "supports_prompt_caching": true + }, + "openrouter/google/gemini-2.5-pro-preview-05-06": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_audio_token": 0.00000125, + "input_cost_per_token_above_200k_tokens": 0.0000025, + "output_cost_per_token_above_200k_tokens": 0.000015, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-2.5-pro-preview-05-06", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "supports_prompt_caching": true }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-8, @@ -34994,6 +37523,46 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3-pro-image": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_audio_token": 0.000002, + "output_cost_per_image_token": 0.00012, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3-pro-image", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/google/gemini-3-pro-image-preview": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_audio_token": 0.000002, + "output_cost_per_image_token": 0.00012, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3-pro-image-preview", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, "openrouter/google/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, @@ -35035,6 +37604,38 @@ "supports_vision": true, "supports_web_search": true }, + "openrouter/google/gemini-3.1-flash-image": { + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.000003, + "output_cost_per_image_token": 0.00006, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3.1-flash-image", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/google/gemini-3.1-flash-image-preview": { + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.000003, + "output_cost_per_image_token": 0.00006, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 58982, + "max_tokens": 58982, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3.1-flash-image-preview", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, "openrouter/google/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-8, "input_cost_per_audio_token": 5e-7, @@ -35078,6 +37679,22 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-flash-lite-image": { + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "output_cost_per_image_token": 0.00003, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 58982, + "max_tokens": 58982, + "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3.1-flash-lite-image", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, "openrouter/google/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-8, "input_cost_per_audio_token": 5e-7, @@ -35154,798 +37771,2881 @@ "supports_tool_choice": true, "supports_vision": true }, - "openrouter/gryphe/mythomax-l2-13b": { - "input_cost_per_token": 0.000001875, - "litellm_provider": "openrouter", - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000001875, - "supports_tool_choice": true - }, - "openrouter/mancer/weaver": { - "input_cost_per_token": 0.000005625, + "openrouter/google/gemini-3.1-pro-preview-customtools": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_audio_token": 0.000002, + "input_cost_per_token_above_200k_tokens": 0.000004, + "output_cost_per_token_above_200k_tokens": 0.000018, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, "litellm_provider": "openrouter", - "max_tokens": 2000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.000005625, + "source": "https://openrouter.ai/google/gemini-3.1-pro-preview-customtools", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 8000, - "max_output_tokens": 2000 + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "supports_prompt_caching": true }, - "openrouter/meta-llama/llama-3-70b-instruct": { - "input_cost_per_token": 5.9e-7, + "openrouter/google/gemini-3.5-flash": { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, "litellm_provider": "openrouter", - "max_tokens": 8000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, "mode": "chat", - "output_cost_per_token": 7.9e-7, + "source": "https://openrouter.ai/google/gemini-3.5-flash", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 8192, - "max_output_tokens": 8000 + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 1.5e-7, + "supports_prompt_caching": true }, - "openrouter/minimax/minimax-m2": { - "input_cost_per_token": 2.55e-7, + "openrouter/google/gemini-3.5-flash-lite": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, "litellm_provider": "openrouter", - "max_input_tokens": 204800, - "max_output_tokens": 204800, - "max_tokens": 204800, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00000102, + "source": "https://openrouter.ai/google/gemini-3.5-flash-lite", "supports_function_calling": true, - "supports_prompt_caching": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 3e-8, + "supports_prompt_caching": true }, - "openrouter/minimax/minimax-m2.1": { - "input_cost_per_token": 2.7e-7, - "output_cost_per_token": 0.0000012, - "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 0, + "openrouter/google/gemini-3.6-flash": { + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.00000375, "litellm_provider": "openrouter", - "max_input_tokens": 204000, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", + "source": "https://openrouter.ai/google/gemini-3.6-flash", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": false, - "supports_computer_use": false + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 7.5e-8, + "supports_prompt_caching": true }, - "openrouter/minimax/minimax-m2.5": { - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000011, - "cache_read_input_token_cost": 1.5e-7, + "openrouter/google/gemini-3.7-flash": { + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.00000375, "litellm_provider": "openrouter", - "max_input_tokens": 196608, + "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2.5", + "source": "https://openrouter.ai/google/gemini-3.7-flash", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false, - "supports_prompt_caching": true, - "supports_computer_use": false + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 7.5e-8, + "supports_prompt_caching": true }, - "openrouter/mistralai/devstral-2512": { - "input_cost_per_image": 0, - "input_cost_per_token": 1.5e-7, + "openrouter/google/gemini-3.8-flash": { + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.00000375, "litellm_provider": "openrouter", - "max_input_tokens": 262144, + "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 6e-7, + "source": "https://openrouter.ai/google/gemini-3.8-flash", "supports_function_calling": true, - "supports_prompt_caching": false, "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": true, + "cache_read_input_token_cost": 7.5e-8, + "supports_prompt_caching": true + }, + "openrouter/google/gemma-2-27b-it": { + "input_cost_per_token": 6.5e-7, + "output_cost_per_token": 6.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "source": "https://openrouter.ai/google/gemma-2-27b-it", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, "supports_vision": false }, - "openrouter/mistralai/ministral-14b-2512": { - "input_cost_per_image": 0, - "input_cost_per_token": 2e-7, + "openrouter/google/gemma-3-12b-it": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 1.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 2e-7, + "source": "https://openrouter.ai/google/gemma-3-12b-it", "supports_function_calling": true, - "supports_prompt_caching": false, "supports_tool_choice": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/mistralai/ministral-3b-2512": { - "input_cost_per_image": 0, - "input_cost_per_token": 1e-7, + "openrouter/google/gemma-3-27b-it": { + "input_cost_per_token": 8e-8, + "output_cost_per_token": 4.5e-7, + "cache_read_input_token_cost": 4e-8, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", - "output_cost_per_token": 1e-7, + "source": "https://openrouter.ai/google/gemma-3-27b-it", "supports_function_calling": true, - "supports_prompt_caching": false, "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/google/gemma-3-4b-it": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 1e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/google/gemma-3-4b-it", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/mistralai/ministral-8b-2512": { - "input_cost_per_image": 0, - "input_cost_per_token": 1.5e-7, + "openrouter/google/gemma-4-26b-a4b-it": { + "input_cost_per_token": 7e-8, + "output_cost_per_token": 3.4e-7, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1.5e-7, + "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it", "supports_function_calling": true, - "supports_prompt_caching": false, "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/mistralai/mistral-7b-instruct": { - "input_cost_per_token": 1.3e-7, + "openrouter/google/gemma-4-26b-a4b-it:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, "litellm_provider": "openrouter", - "max_tokens": 8191, + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 1.3e-7, + "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it:free", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 32768, - "max_output_tokens": 8191 + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true }, - "openrouter/mistralai/mistral-large": { - "input_cost_per_token": 0.000008, + "openrouter/google/gemma-4-31b-it": { + "input_cost_per_token": 9e-8, + "output_cost_per_token": 3.4e-7, + "cache_read_input_token_cost": 5e-8, "litellm_provider": "openrouter", - "max_tokens": 8191, + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 0.000024, + "source": "https://openrouter.ai/google/gemma-4-31b-it", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 128000, - "max_output_tokens": 8191 + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, - "openrouter/mistralai/mistral-large-2512": { - "input_cost_per_image": 0, - "input_cost_per_token": 5e-7, + "openrouter/google/gemma-4-31b-it:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, "litellm_provider": "openrouter", "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.0000015, + "source": "https://openrouter.ai/google/gemma-4-31b-it:free", "supports_function_calling": true, - "supports_prompt_caching": false, "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/mistralai/mistral-small-3.1-24b-instruct": { - "input_cost_per_token": 1e-7, + "openrouter/gryphe/mythomax-l2-13b": { + "input_cost_per_token": 6e-8, "litellm_provider": "openrouter", - "max_tokens": 131072, + "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 3e-7, - "supports_tool_choice": true, - "max_input_tokens": 131072, - "max_output_tokens": 131072 + "output_cost_per_token": 6e-8, + "supports_tool_choice": true }, - "openrouter/mistralai/mistral-small-3.2-24b-instruct": { - "input_cost_per_token": 1e-7, + "openrouter/mancer/weaver": { + "input_cost_per_token": 4e-7, "litellm_provider": "openrouter", - "max_tokens": 128000, + "max_tokens": 2000, "mode": "chat", - "output_cost_per_token": 3e-7, + "output_cost_per_token": 7.5e-7, "supports_tool_choice": true, - "max_input_tokens": 128000, - "max_output_tokens": 128000 + "max_input_tokens": 8000, + "max_output_tokens": 2000 }, - "openrouter/mistralai/mixtral-8x22b-instruct": { - "input_cost_per_token": 6.5e-7, + "openrouter/meta-llama/llama-3-70b-instruct": { + "input_cost_per_token": 5.9e-7, "litellm_provider": "openrouter", - "max_tokens": 65536, + "max_tokens": 8000, "mode": "chat", - "output_cost_per_token": 6.5e-7, + "output_cost_per_token": 7.9e-7, "supports_tool_choice": true, - "max_input_tokens": 65536, - "max_output_tokens": 65536 + "max_input_tokens": 8192, + "max_output_tokens": 8000 }, - "openrouter/moonshotai/kimi-k2.5": { - "cache_read_input_token_cost": 1e-7, - "input_cost_per_token": 6e-7, + "openrouter/meta-llama/llama-3.1-70b-instruct": { + "input_cost_per_token": 4e-7, + "output_cost_per_token": 4e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 0.000003, - "source": "https://openrouter.ai/moonshotai/kimi-k2.5", + "source": "https://openrouter.ai/meta-llama/llama-3.1-70b-instruct", "supports_function_calling": true, "supports_tool_choice": true, - "supports_video_input": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/meta-llama/llama-3.1-8b-instruct": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 8e-8, + "cache_read_input_token_cost": 2.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-3.1-8b-instruct", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/meta-llama/llama-3.2-1b-instruct": { + "input_cost_per_token": 2.7e-8, + "output_cost_per_token": 2.01e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 60000, + "max_output_tokens": 54000, + "max_tokens": 54000, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-3.2-1b-instruct", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_vision": false + }, + "openrouter/meta-llama/llama-3.2-3b-instruct": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 3.3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-3.2-3b-instruct", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/meta-llama/llama-3.3-70b-instruct": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3.2e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-3.3-70b-instruct", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/meta-llama/llama-4-maverick": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6.96e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-4-maverick", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/meta-llama/llama-4-scout": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-4-scout", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/meta-llama/llama-guard-4-12b": { + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 1.8e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 163840, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/meta-llama/llama-guard-4-12b", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/minimax/minimax-01": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000011, + "litellm_provider": "openrouter", + "max_input_tokens": 1000192, + "max_output_tokens": 900172, + "max_tokens": 900172, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-01", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_vision": true + }, + "openrouter/minimax/minimax-m1": { + "input_cost_per_token": 5.5e-7, + "output_cost_per_token": 0.0000022, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/minimax/minimax-m2": { + "input_cost_per_token": 2.55e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 204800, + "max_output_tokens": 204800, + "max_tokens": 204800, + "mode": "chat", + "output_cost_per_token": 0.00000102, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/minimax/minimax-m2-her": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m2-her", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/minimax/minimax-m2.1": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 204000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_computer_use": false + }, + "openrouter/minimax/minimax-m2.5": { + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 0.00000108, + "cache_read_input_token_cost": 2.7e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m2.5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_prompt_caching": true, + "supports_computer_use": false + }, + "openrouter/minimax/minimax-m2.7": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 6e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m2.7", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/minimax/minimax-m2.7:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 196608, + "max_output_tokens": 176947, + "max_tokens": 176947, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m2.7:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/minimax/minimax-m3": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 512000, + "max_tokens": 512000, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "cache_read_input_token_cost": 6e-8, + "supports_prompt_caching": true + }, + "openrouter/minimax/minimax-m3:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "source": "https://openrouter.ai/minimax/minimax-m3:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/mistralai/codestral-2508": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 204800, + "max_tokens": 204800, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/codestral-2508", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/devstral-2512": { + "input_cost_per_image": 0, + "input_cost_per_token": 4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.000002, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/mistralai/ministral-14b-2512": { + "input_cost_per_image": 0, + "input_cost_per_token": 2e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-7, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/mistralai/ministral-3b-2512": { + "input_cost_per_image": 0, + "input_cost_per_token": 1e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-7, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/mistralai/ministral-8b-2512": { + "input_cost_per_image": 0, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-7, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/mistralai/mistral-7b-instruct": { + "input_cost_per_token": 1.3e-7, + "litellm_provider": "openrouter", + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 1.3e-7, + "supports_tool_choice": true, + "max_input_tokens": 32768, + "max_output_tokens": 8191 + }, + "openrouter/mistralai/mistral-large": { + "input_cost_per_token": 0.000002, + "litellm_provider": "openrouter", + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000006, + "supports_tool_choice": true, + "max_input_tokens": 128000, + "max_output_tokens": 8191 + }, + "openrouter/mistralai/mistral-large-2407": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 2e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-large-2407", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/mistral-large-2512": { + "input_cost_per_image": 0, + "input_cost_per_token": 5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/mistralai/mistral-medium-3": { + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 4e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-medium-3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/mistral-medium-3-5": { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.0000075, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-medium-3-5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/mistralai/mistral-medium-3.1": { + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 4e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-medium-3.1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/mistral-nemo": { + "input_cost_per_token": 1.9e-8, + "output_cost_per_token": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-nemo", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/mistralai/mistral-saba": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 2e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 26214, + "max_tokens": 26214, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-saba", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/mistral-small-24b-instruct-2501": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 8e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-small-24b-instruct-2501", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/mistralai/mistral-small-2603": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 1.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/mistral-small-2603", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/mistralai/mistral-small-3.1-24b-instruct": { + "input_cost_per_token": 3.51e-7, + "litellm_provider": "openrouter", + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5.55e-7, + "supports_tool_choice": true, + "max_input_tokens": 131072, + "max_output_tokens": 131072 + }, + "openrouter/mistralai/mistral-small-3.2-24b-instruct": { + "input_cost_per_token": 7.5e-8, + "litellm_provider": "openrouter", + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-7, + "supports_tool_choice": true, + "max_input_tokens": 128000, + "max_output_tokens": 128000 + }, + "openrouter/mistralai/mixtral-8x22b-instruct": { + "input_cost_per_token": 0.000002, + "litellm_provider": "openrouter", + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.000006, + "supports_tool_choice": true, + "max_input_tokens": 65536, + "max_output_tokens": 65536 + }, + "openrouter/mistralai/voxtral-small-24b-2507": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 1e-8, + "input_cost_per_audio_token": 0.0001, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 26214, + "max_tokens": 26214, + "mode": "chat", + "source": "https://openrouter.ai/mistralai/voxtral-small-24b-2507", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_pdf_input": true, + "supports_audio_input": true, + "supports_prompt_caching": true + }, + "openrouter/moonshotai/kimi-k2": { + "input_cost_per_token": 5.7e-7, + "output_cost_per_token": 0.0000023, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 100352, + "max_tokens": 100352, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k2", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/moonshotai/kimi-k2-0905": { + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 100352, + "max_tokens": 100352, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k2-0905", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/moonshotai/kimi-k2-thinking": { + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 1.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 100352, + "max_tokens": 100352, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k2-thinking", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/moonshotai/kimi-k2.5": { + "cache_read_input_token_cost": 7e-8, + "input_cost_per_token": 4.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.00000225, + "source": "https://openrouter.ai/moonshotai/kimi-k2.5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true + }, + "openrouter/moonshotai/kimi-k2.6": { + "input_cost_per_token": 9.5e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 1.6e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k2.6", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/moonshotai/kimi-k2.7-code": { + "input_cost_per_token": 6.6e-7, + "output_cost_per_token": 0.0000034, + "cache_read_input_token_cost": 1.8e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k2.7-code", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/moonshotai/kimi-k3": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "source": "https://openrouter.ai/moonshotai/kimi-k3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/nvidia/nemotron-3-nano-30b-a3b": { + "input_cost_per_token": 5e-8, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-nano-30b-a3b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_audio_input": true + }, + "openrouter/nvidia/nemotron-3-super-120b-a12b": { + "input_cost_per_token": 8.5e-8, + "output_cost_per_token": 4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/nvidia/nemotron-3-super-120b-a12b:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/nvidia/nemotron-3-ultra-550b-a55b": { + "input_cost_per_token": 6.25e-7, + "output_cost_per_token": 0.000003125, + "cache_read_input_token_cost": 1.875e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/nvidia/nemotron-3.5-content-safety": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 2e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/nvidia/nemotron-3.5-content-safety:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety:free", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_vision": true + }, + "openrouter/nvidia/nemotron-3.5-lightning": { + "input_cost_per_token": 8e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-7, + "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/nvidia/nemotron-3.5-lightning:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/openai/gpt-3.5-turbo": { + "input_cost_per_token": 5e-7, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_tool_choice": true, + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "source": "https://openrouter.ai/openai/gpt-3.5-turbo" + }, + "openrouter/openai/gpt-3.5-turbo-16k": { + "input_cost_per_token": 0.000003, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_tool_choice": true, + "max_input_tokens": 16385, + "max_output_tokens": 4096 + }, + "openrouter/openai/gpt-3.5-turbo-instruct": { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + "litellm_provider": "openrouter", + "max_input_tokens": 4095, + "max_output_tokens": 3685, + "max_tokens": 3685, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-3.5-turbo-instruct", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/openai/gpt-4": { + "input_cost_per_token": 0.00003, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00006, + "supports_tool_choice": true, + "max_input_tokens": 8191, + "max_output_tokens": 4096 + }, + "openrouter/openai/gpt-4-turbo": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4-turbo", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4-turbo-preview": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4-turbo-preview", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/openai/gpt-4.1": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000002, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.000008, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-mini": { + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0000016, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.5e-8, + "input_cost_per_token": 1e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-7, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4o": { + "input_cost_per_token": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00001, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "cache_read_input_token_cost": 0.00000125, + "supports_prompt_caching": true, + "source": "https://openrouter.ai/openai/gpt-4o" + }, + "openrouter/openai/gpt-4o-2024-05-13": { + "input_cost_per_token": 0.000005, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4o-2024-08-06": { + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4o-2024-08-06", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_web_search": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-4o-2024-11-20": { + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4o-2024-11-20", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_web_search": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-4o-mini": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4o-mini", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 7.5e-8, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-4o-mini-2024-07-18": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 7.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-4o-mini-2024-07-18", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_web_search": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00001, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-chat": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00001, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00001, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-mini": { + "cache_read_input_token_cost": 2.5e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000002, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-nano": { + "cache_read_input_token_cost": 5e-9, + "input_cost_per_token": 5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-7, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-pro": { + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00012, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5-pro", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/openai/gpt-5.1": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 1.25e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.1-codex": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.1-codex", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00001, + "source": "https://openrouter.ai/openai/gpt-5.1-codex-max", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-5.1-codex-mini": { + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.1-codex-mini", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.2": { + "input_cost_per_image": 0, + "cache_read_input_token_cost": 1.75e-7, + "input_cost_per_token": 0.00000175, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000014, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-5.2-chat": { + "input_cost_per_image": 0, + "cache_read_input_token_cost": 1.75e-7, + "input_cost_per_token": 0.00000175, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.000014, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-5.2-codex": { + "cache_read_input_token_cost": 1.75e-7, + "input_cost_per_token": 0.00000175, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000014, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5.2-pro": { + "input_cost_per_image": 0, + "input_cost_per_token": 0.000021, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000168, + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-5.3-codex": { + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.3-codex", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 1.75e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.4": { + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.4", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2.5e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.4-mini": { + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.4-mini", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 7.5e-8, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.4-nano": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.4-nano", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-8, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.4-pro": { + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00018, + "input_cost_per_token_above_272k_tokens": 0.00006, + "output_cost_per_token_above_272k_tokens": 0.00027, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.4-pro", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/openai/gpt-5.5": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 5e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.5-pro": { + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00018, + "input_cost_per_token_above_272k_tokens": 0.00006, + "output_cost_per_token_above_272k_tokens": 0.00027, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.5-pro", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/openai/gpt-5.6-luna": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "openrouter", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.6-luna", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-8, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-5.6-terra": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "litellm_provider": "openrouter", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-5.6-terra", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-6-astra": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "cache_creation_input_token_cost": 0.0000125, + "input_cost_per_token_above_272k_tokens": 0.00002, + "output_cost_per_token_above_272k_tokens": 0.000075, + "cache_read_input_token_cost_above_272k_tokens": 0.000002, + "cache_creation_input_token_cost_above_272k_tokens": 0.000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-6-astra", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/openai/gpt-audio": { + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "input_cost_per_audio_token": 0.000032, + "output_cost_per_audio_token": 0.000064, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-audio", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_audio_input": true + }, + "openrouter/openai/gpt-audio-mini": { + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000024, + "input_cost_per_audio_token": 6e-7, + "output_cost_per_audio_token": 0.0000024, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-audio-mini", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_audio_input": true + }, + "openrouter/openai/gpt-oss-120b": { + "input_cost_per_token": 3.7e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.7e-7, + "source": "https://openrouter.ai/openai/gpt-oss-120b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-oss-20b": { + "input_cost_per_token": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.3e-7, + "source": "https://openrouter.ai/openai/gpt-oss-20b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-oss-safeguard-20b": { + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 3.75e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://openrouter.ai/openai/gpt-oss-safeguard-20b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/openai/o1": { + "cache_read_input_token_cost": 0.0000075, + "input_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 0.00006, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, "supports_vision": true }, - "openrouter/nvidia/nemotron-3.5-lightning": { - "input_cost_per_token": 5e-8, + "openrouter/openai/o1-pro": { + "input_cost_per_token": 0.00015, + "output_cost_per_token": 0.0006, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "source": "https://openrouter.ai/openai/o1-pro", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/openai/o3": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "source": "https://openrouter.ai/openai/o3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 5e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/o3-mini": { + "input_cost_per_token": 0.0000011, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false, + "cache_read_input_token_cost": 5.5e-7, + "supports_prompt_caching": true, + "source": "https://openrouter.ai/openai/o3-mini" + }, + "openrouter/openai/o3-mini-high": { + "input_cost_per_token": 0.0000011, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false, + "cache_read_input_token_cost": 5.5e-7, + "supports_prompt_caching": true, + "source": "https://openrouter.ai/openai/o3-mini-high" + }, + "openrouter/openai/o3-pro": { + "input_cost_per_token": 0.00002, + "output_cost_per_token": 0.00008, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "source": "https://openrouter.ai/openai/o3-pro", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true + }, + "openrouter/openai/o4-mini": { + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "source": "https://openrouter.ai/openai/o4-mini", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2.75e-7, + "supports_prompt_caching": true + }, + "openrouter/openai/o4-mini-high": { + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 2.75e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "source": "https://openrouter.ai/openai/o4-mini-high", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_prompt_caching": true + }, + "openrouter/openrouter/auto": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_audio_input": true, + "supports_video_input": true + }, + "openrouter/openrouter/bodybuilder": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "chat" + }, + "openrouter/openrouter/free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true + }, + "openrouter/poolside/laguna-s-2.1": { + "input_cost_per_token": 9e-8, + "output_cost_per_token": 1.8e-7, + "cache_read_input_token_cost": 9e-9, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/poolside/laguna-s-2.1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/poolside/laguna-s-2.1:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, "litellm_provider": "openrouter", "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 2e-7, - "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning", + "source": "https://openrouter.ai/poolside/laguna-s-2.1:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/poolside/laguna-xs-2.1": { + "input_cost_per_token": 6e-8, + "output_cost_per_token": 1.2e-7, + "cache_read_input_token_cost": 3e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/poolside/laguna-xs-2.1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/poolside/laguna-xs-2.1:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/poolside/laguna-xs-2.1:free", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, + "supports_vision": false + }, + "openrouter/qwen/qwen-2.5-72b-instruct": { + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen-2.5-72b-instruct", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen-2.5-7b-instruct": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 2e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen-2.5-7b-instruct", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen-2.5-coder-32b-instruct": { + "input_cost_per_token": 6.6e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 33792, + "max_output_tokens": 33792, + "max_tokens": 33792, + "mode": "chat", + "output_cost_per_token": 0.000001, "supports_tool_choice": true }, - "openrouter/openai/gpt-3.5-turbo": { - "input_cost_per_token": 0.0000015, + "openrouter/qwen/qwen-plus": { + "input_cost_per_token": 2.6e-7, + "output_cost_per_token": 7.8e-7, + "cache_read_input_token_cost": 5.2e-8, + "cache_creation_input_token_cost": 3.25e-7, + "input_cost_per_token_above_256k_tokens": 7.8e-7, + "output_cost_per_token_above_256k_tokens": 0.00000234, + "cache_read_input_token_cost_above_256k_tokens": 1.56e-7, + "cache_creation_input_token_cost_above_256k_tokens": 9.75e-7, "litellm_provider": "openrouter", - "max_tokens": 4096, + "max_input_tokens": 1000000, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000002, + "source": "https://openrouter.ai/qwen/qwen-plus", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 16385, - "max_output_tokens": 4096 + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-3.5-turbo-16k": { - "input_cost_per_token": 0.000003, + "openrouter/qwen/qwen-plus-2025-07-28": { + "input_cost_per_token": 2.6e-7, + "output_cost_per_token": 7.8e-7, + "input_cost_per_token_above_256k_tokens": 7.8e-7, + "output_cost_per_token_above_256k_tokens": 0.00000234, "litellm_provider": "openrouter", - "max_tokens": 4096, + "max_input_tokens": 1000000, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000004, + "source": "https://openrouter.ai/qwen/qwen-plus-2025-07-28", + "supports_function_calling": true, "supports_tool_choice": true, - "max_input_tokens": 16385, - "max_output_tokens": 4096 + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/openai/gpt-4": { - "input_cost_per_token": 0.00003, + "openrouter/qwen/qwen-vl-plus": { + "input_cost_per_token": 2.1e-7, "litellm_provider": "openrouter", - "max_tokens": 4096, + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, "mode": "chat", - "output_cost_per_token": 0.00006, + "output_cost_per_token": 6.3e-7, "supports_tool_choice": true, - "max_input_tokens": 8191, - "max_output_tokens": 4096 + "supports_vision": true }, - "openrouter/openai/gpt-4.1": { - "cache_read_input_token_cost": 5e-7, - "input_cost_per_token": 0.000002, + "openrouter/qwen/qwen2.5-vl-72b-instruct": { + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.000001, + "cache_read_input_token_cost": 4e-7, "litellm_provider": "openrouter", - "max_input_tokens": 1047576, + "max_input_tokens": 128000, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen2.5-vl-72b-instruct", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/qwen/qwen3-14b": { + "input_cost_per_token": 1.2e-7, + "output_cost_per_token": 2.4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3-14b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-235b-a22b": { + "input_cost_per_token": 4.55e-7, + "output_cost_per_token": 0.00000182, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3-235b-a22b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-235b-a22b-2507": { + "input_cost_per_token": 8.75e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.5e-7, + "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-2507", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3-235b-a22b-thinking-2507": { + "input_cost_per_token": 2.3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0000023, + "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-thinking-2507", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3-30b-a3b": { + "input_cost_per_token": 1.2e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3-30b-a3b", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-30b-a3b-instruct-2507": { + "input_cost_per_token": 4.815e-8, + "output_cost_per_token": 1.9305e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-instruct-2507", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-30b-a3b-thinking-2507": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000024, + "litellm_provider": "openrouter", + "max_input_tokens": 81920, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000008, + "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-thinking-2507", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-32b": { + "input_cost_per_token": 8e-8, + "output_cost_per_token": 2.8e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3-32b", "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/openai/gpt-4.1-mini": { - "cache_read_input_token_cost": 1e-7, - "input_cost_per_token": 4e-7, + "openrouter/qwen/qwen3-8b": { + "input_cost_per_token": 1.17e-7, + "output_cost_per_token": 4.55e-7, "litellm_provider": "openrouter", - "max_input_tokens": 1047576, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.0000016, + "source": "https://openrouter.ai/qwen/qwen3-8b", "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, + "supports_tool_choice": true, + "supports_reasoning": true, "supports_response_schema": true, - "supports_system_messages": true, + "supports_vision": false + }, + "openrouter/qwen/qwen3-coder": { + "input_cost_per_token": 3e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 262100, + "max_output_tokens": 262100, + "max_tokens": 262100, + "mode": "chat", + "output_cost_per_token": 0.000001, + "source": "https://openrouter.ai/qwen/qwen3-coder", "supports_tool_choice": true, - "supports_vision": true + "supports_function_calling": true }, - "openrouter/openai/gpt-4.1-nano": { - "cache_read_input_token_cost": 2.5e-8, - "input_cost_per_token": 1e-7, + "openrouter/qwen/qwen3-coder-30b-a3b-instruct": { + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, "litellm_provider": "openrouter", - "max_input_tokens": 1047576, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "output_cost_per_token": 4e-7, + "source": "https://openrouter.ai/qwen/qwen3-coder-30b-a3b-instruct", "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/openai/gpt-4o": { - "input_cost_per_token": 0.0000025, + "openrouter/qwen/qwen3-coder-flash": { + "input_cost_per_token": 1.95e-7, + "output_cost_per_token": 9.75e-7, + "cache_read_input_token_cost": 3.9e-8, + "cache_creation_input_token_cost": 2.4375e-7, + "input_cost_per_token_above_128k_tokens": 5.2e-7, + "output_cost_per_token_above_128k_tokens": 0.0000026, + "cache_read_input_token_cost_above_128k_tokens": 1.04e-7, + "cache_creation_input_token_cost_above_128k_tokens": 6.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00001, + "source": "https://openrouter.ai/qwen/qwen3-coder-flash", "supports_function_calling": true, - "supports_parallel_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-4o-2024-05-13": { - "input_cost_per_token": 0.000005, + "openrouter/qwen/qwen3-coder-next": { + "input_cost_per_token": 1.2e-7, + "output_cost_per_token": 8e-7, + "cache_read_input_token_cost": 7e-8, "litellm_provider": "openrouter", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "output_cost_per_token": 0.000015, + "source": "https://openrouter.ai/qwen/qwen3-coder-next", "supports_function_calling": true, - "supports_parallel_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-5": { - "cache_read_input_token_cost": 1.25e-7, - "input_cost_per_token": 0.00000125, + "openrouter/qwen/qwen3-coder-plus": { + "input_cost_per_token": 6.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00001, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], + "output_cost_per_token": 0.00000325, + "source": "https://openrouter.ai/qwen/qwen3-coder-plus", + "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, - "openrouter/openai/gpt-5-chat": { - "cache_read_input_token_cost": 1.25e-7, - "input_cost_per_token": 0.00000125, + "openrouter/qwen/qwen3-max": { + "input_cost_per_token": 7.8e-7, + "output_cost_per_token": 0.0000039, + "cache_read_input_token_cost": 1.56e-7, + "cache_creation_input_token_cost": 9.75e-7, + "input_cost_per_token_above_128k_tokens": 0.00000195, + "output_cost_per_token_above_128k_tokens": 0.00000975, + "cache_read_input_token_cost_above_128k_tokens": 3.9e-7, + "cache_creation_input_token_cost_above_128k_tokens": 0.0000024375, "litellm_provider": "openrouter", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00001, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_reasoning": true, - "supports_tool_choice": true + "source": "https://openrouter.ai/qwen/qwen3-max", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-5-codex": { - "cache_read_input_token_cost": 1.25e-7, - "input_cost_per_token": 0.00000125, + "openrouter/qwen/qwen3-max-thinking": { + "input_cost_per_token": 7.8e-7, + "output_cost_per_token": 0.0000039, + "input_cost_per_token_above_128k_tokens": 0.00000195, + "output_cost_per_token_above_128k_tokens": 0.00000975, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00001, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], + "source": "https://openrouter.ai/qwen/qwen3-max-thinking", + "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/openai/gpt-5-mini": { - "cache_read_input_token_cost": 2.5e-8, - "input_cost_per_token": 2.5e-7, + "openrouter/qwen/qwen3-next-80b-a3b-instruct": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 0.0000011, + "cache_read_input_token_cost": 7e-8, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "output_cost_per_token": 0.000002, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_reasoning": true, - "supports_tool_choice": true + "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-5-nano": { - "cache_read_input_token_cost": 5e-9, - "input_cost_per_token": 5e-8, + "openrouter/qwen/qwen3-next-80b-a3b-thinking": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 0.0000012, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 4e-7, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], + "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-thinking", + "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/openai/gpt-5.1-codex-max": { - "cache_read_input_token_cost": 1.25e-7, - "input_cost_per_token": 0.00000125, + "openrouter/qwen/qwen3-vl-235b-a22b-instruct": { + "input_cost_per_token": 2.1e-7, + "output_cost_per_token": 0.0000019, + "cache_read_input_token_cost": 1e-7, "litellm_provider": "openrouter", - "max_input_tokens": 400000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.00001, - "source": "https://openrouter.ai/openai/gpt-5.1-codex-max", - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], + "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-instruct", "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, - "openrouter/openai/gpt-5.2": { - "input_cost_per_image": 0, - "cache_read_input_token_cost": 1.75e-7, - "input_cost_per_token": 0.00000175, + "openrouter/qwen/qwen3-vl-235b-a22b-thinking": { + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000004, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000014, + "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-thinking", "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/openai/gpt-5.2-chat": { - "input_cost_per_image": 0, - "cache_read_input_token_cost": 1.75e-7, - "input_cost_per_token": 0.00000175, + "openrouter/qwen/qwen3-vl-30b-a3b-instruct": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, "litellm_provider": "openrouter", - "max_input_tokens": 128000, + "max_input_tokens": 262144, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 0.000014, + "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-instruct", "supports_function_calling": true, - "supports_prompt_caching": true, "supports_tool_choice": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/openai/gpt-5.2-codex": { - "cache_read_input_token_cost": 1.75e-7, - "input_cost_per_token": 0.00000175, + "openrouter/qwen/qwen3-vl-30b-a3b-thinking": { + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000024, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000014, - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], + "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-thinking", + "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": true }, - "openrouter/openai/gpt-5.2-pro": { - "input_cost_per_image": 0, - "input_cost_per_token": 0.000021, + "openrouter/qwen/qwen3-vl-32b-instruct": { + "input_cost_per_token": 1.04e-7, + "output_cost_per_token": 4.16e-7, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 0.000168, + "source": "https://openrouter.ai/qwen/qwen3-vl-32b-instruct", "supports_function_calling": true, - "supports_prompt_caching": false, - "supports_reasoning": true, "supports_tool_choice": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/openai/gpt-oss-120b": { - "input_cost_per_token": 1.8e-7, + "openrouter/qwen/qwen3-vl-8b-instruct": { + "input_cost_per_token": 1.17e-7, + "output_cost_per_token": 4.55e-7, "litellm_provider": "openrouter", - "max_input_tokens": 131072, + "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 8e-7, - "source": "https://openrouter.ai/openai/gpt-oss-120b", + "source": "https://openrouter.ai/qwen/qwen3-vl-8b-instruct", "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_reasoning": true, + "supports_tool_choice": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_vision": true }, - "openrouter/openai/gpt-oss-20b": { - "input_cost_per_token": 2e-8, + "openrouter/qwen/qwen3-vl-8b-thinking": { + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 0.0000021, "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 1e-7, - "source": "https://openrouter.ai/openai/gpt-oss-20b", + "source": "https://openrouter.ai/qwen/qwen3-vl-8b-thinking", "supports_function_calling": true, - "supports_parallel_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_vision": true }, - "openrouter/openai/o1": { - "cache_read_input_token_cost": 0.0000075, - "input_cost_per_token": 0.000015, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 2.9e-7, "litellm_provider": "openrouter", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.00006, + "output_cost_per_token": 0.0000024, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, + "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true }, - "openrouter/openai/o3-mini": { - "input_cost_per_token": 0.0000011, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 1.95e-7, "litellm_provider": "openrouter", - "max_input_tokens": 128000, + "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.0000044, + "output_cost_per_token": 0.00000156, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", "supports_function_calling": true, - "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": true }, - "openrouter/openai/o3-mini-high": { - "input_cost_per_token": 0.0000011, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 128000, + "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.0000044, + "output_cost_per_token": 0.00000125, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", "supports_function_calling": true, - "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": true }, - "openrouter/openrouter/auto": { - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 5.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 2000000, - "max_tokens": 2000000, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", + "output_cost_per_token": 0.0000035, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", "supports_function_calling": true, - "supports_tool_choice": true, "supports_reasoning": true, - "supports_response_schema": true, - "supports_vision": true, - "supports_audio_input": true, - "supports_video_input": true - }, - "openrouter/openrouter/bodybuilder": { - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "litellm_provider": "openrouter", - "max_input_tokens": 128000, - "max_tokens": 128000, - "mode": "chat" + "supports_tool_choice": true, + "supports_vision": true }, - "openrouter/openrouter/free": { - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "openrouter/qwen/qwen3.5-9b": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 200000, - "max_tokens": 200000, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3.5-9b", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true }, - "openrouter/qwen/qwen-2.5-coder-32b-instruct": { - "input_cost_per_token": 1.8e-7, - "litellm_provider": "openrouter", - "max_input_tokens": 33792, - "max_output_tokens": 33792, - "max_tokens": 33792, - "mode": "chat", - "output_cost_per_token": 1.8e-7, - "supports_tool_choice": true - }, - "openrouter/qwen/qwen-vl-plus": { - "input_cost_per_token": 2.1e-7, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 6.5e-8, "litellm_provider": "openrouter", - "max_input_tokens": 8192, - "max_output_tokens": 2048, - "max_tokens": 2048, + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 6.3e-7, + "output_cost_per_token": 2.6e-7, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true }, - "openrouter/qwen/qwen3-235b-a22b-2507": { - "input_cost_per_token": 7.1e-8, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 2.6e-7, + "input_cost_per_token_above_256k_tokens": 5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 1e-7, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-2507", + "output_cost_per_token": 0.00000156, + "output_cost_per_token_above_256k_tokens": 0.000003, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", "supports_function_calling": true, - "supports_tool_choice": true + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, - "openrouter/qwen/qwen3-235b-a22b-thinking-2507": { - "input_cost_per_token": 1.1e-7, + "openrouter/qwen/qwen3.5-plus-20260420": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000018, + "cache_creation_input_token_cost": 3.75e-7, + "input_cost_per_token_above_256k_tokens": 3.75e-7, + "output_cost_per_token_above_256k_tokens": 0.00000225, + "cache_creation_input_token_cost_above_256k_tokens": 4.6875e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 6e-7, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-thinking-2507", + "source": "https://openrouter.ai/qwen/qwen3.5-plus-20260420", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": true }, - "openrouter/qwen/qwen3-coder": { - "input_cost_per_token": 2.2e-7, + "openrouter/qwen/qwen3.6-27b": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 3e-8, "litellm_provider": "openrouter", - "max_input_tokens": 262100, - "max_output_tokens": 262100, - "max_tokens": 262100, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 9.5e-7, - "source": "https://openrouter.ai/qwen/qwen3-coder", + "source": "https://openrouter.ai/qwen/qwen3.6-27b", + "supports_function_calling": true, "supports_tool_choice": true, - "supports_function_calling": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, - "openrouter/qwen/qwen3-coder-plus": { - "input_cost_per_token": 0.000001, + "openrouter/qwen/qwen3.6-35b-a3b": { + "input_cost_per_token": 1e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": 5e-8, "litellm_provider": "openrouter", - "max_input_tokens": 997952, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "output_cost_per_token": 0.000005, - "source": "https://openrouter.ai/qwen/qwen3-coder-plus", + "source": "https://openrouter.ai/qwen/qwen3.6-35b-a3b", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, - "openrouter/qwen/qwen3.5-122b-a10b": { - "input_cost_per_token": 4e-7, + "openrouter/qwen/qwen3.6-flash": { + "input_cost_per_token": 1.875e-7, + "output_cost_per_token": 0.000001125, + "cache_creation_input_token_cost": 2.34375e-7, + "input_cost_per_token_above_256k_tokens": 7.5e-7, + "output_cost_per_token_above_256k_tokens": 0.000003, + "cache_creation_input_token_cost_above_256k_tokens": 9.375e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, + "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.000002, - "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "source": "https://openrouter.ai/qwen/qwen3.6-flash", "supports_function_calling": true, - "supports_reasoning": true, "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_vision": true }, - "openrouter/qwen/qwen3.5-27b": { - "input_cost_per_token": 3e-7, + "openrouter/qwen/qwen3.6-max-preview": { + "input_cost_per_token": 0.000001027, + "output_cost_per_token": 0.000006162, + "cache_creation_input_token_cost": 0.00000128375, + "input_cost_per_token_above_128k_tokens": 0.00000158, + "output_cost_per_token_above_128k_tokens": 0.00000948, + "cache_creation_input_token_cost_above_128k_tokens": 0.000001975, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.0000024, - "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "source": "https://openrouter.ai/qwen/qwen3.6-max-preview", "supports_function_calling": true, - "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false }, - "openrouter/qwen/qwen3.5-35b-a3b": { - "input_cost_per_token": 2.5e-7, + "openrouter/qwen/qwen3.6-plus": { + "input_cost_per_token": 3.25e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, + "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.000002, - "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "output_cost_per_token": 0.00000195, + "source": "https://openrouter.ai/qwen/qwen3.6-plus", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true }, - "openrouter/qwen/qwen3.5-397b-a17b": { - "input_cost_per_token": 6e-7, + "openrouter/qwen/qwen3.7-flash": { + "input_cost_per_token": 3e-8, + "output_cost_per_token": 1.3e-7, + "cache_read_input_token_cost": 6e-9, + "cache_creation_input_token_cost": 3.8e-8, + "input_cost_per_token_above_256k_tokens": 2e-7, + "output_cost_per_token_above_256k_tokens": 8e-7, + "cache_read_input_token_cost_above_256k_tokens": 4e-8, + "cache_creation_input_token_cost_above_256k_tokens": 2.5e-7, "litellm_provider": "openrouter", - "max_input_tokens": 262144, + "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 0.0000036, - "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "source": "https://openrouter.ai/qwen/qwen3.7-flash", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/qwen/qwen3.7-max": { + "input_cost_per_token": 0.000001475, + "output_cost_per_token": 0.000004425, + "cache_read_input_token_cost": 2.95e-7, + "cache_creation_input_token_cost": 0.00000184375, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3.7-max", + "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/qwen/qwen3.5-flash-02-23": { - "input_cost_per_token": 1e-7, + "openrouter/qwen/qwen3.7-plus": { + "input_cost_per_token": 3.2e-7, + "output_cost_per_token": 0.00000128, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 4e-7, - "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "source": "https://openrouter.ai/qwen/qwen3.7-plus", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "cache_read_input_token_cost": 6.4e-8, + "supports_prompt_caching": true, + "cache_creation_input_token_cost": 4e-7 + }, + "openrouter/qwen/qwen3.8-2.4t-a95b": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 2.5e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3.8-2.4t-a95b", + "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true }, - "openrouter/qwen/qwen3.5-plus-02-15": { - "input_cost_per_token": 4e-7, - "input_cost_per_token_above_256k_tokens": 5e-7, + "openrouter/qwen/qwen3.8-27b": { + "input_cost_per_token": 4.2e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": 8.5e-8, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 0.0000024, - "output_cost_per_token_above_256k_tokens": 0.000003, - "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "source": "https://openrouter.ai/qwen/qwen3.8-27b", "supports_function_calling": true, - "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, - "openrouter/qwen/qwen3.6-plus": { - "input_cost_per_token": 3.25e-7, + "openrouter/qwen/qwen3.8-flash": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 4.7e-7, + "cache_read_input_token_cost": 1.6e-8, + "cache_creation_input_token_cost": 2e-7, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 0.00000195, - "source": "https://openrouter.ai/qwen/qwen3.6-plus", + "source": "https://openrouter.ai/qwen/qwen3.8-flash", "supports_function_calling": true, + "supports_tool_choice": true, "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/qwen/qwen3.8-max": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 2.5e-7, + "cache_creation_input_token_cost": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/qwen/qwen3.8-max", + "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-7, @@ -35959,11 +40659,11 @@ "supports_tool_choice": true }, "openrouter/undi95/remm-slerp-l2-13b": { - "input_cost_per_token": 0.000001875, + "input_cost_per_token": 4.5e-7, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", - "output_cost_per_token": 0.000001875, + "output_cost_per_token": 6.5e-7, "supports_tool_choice": true, "max_input_tokens": 6144, "max_output_tokens": 4096 @@ -35982,6 +40682,120 @@ "supports_tool_choice": true, "supports_web_search": true }, + "openrouter/x-ai/grok-4.20": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-4.20", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true + }, + "openrouter/x-ai/grok-4.20-multi-agent": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-4.20-multi-agent", + "supports_function_calling": false, + "supports_tool_choice": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true + }, + "openrouter/x-ai/grok-4.3": { + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.0000025, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-4.3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true + }, + "openrouter/x-ai/grok-4.5": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "litellm_provider": "openrouter", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-4.5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 3e-7, + "supports_prompt_caching": true + }, + "openrouter/x-ai/grok-4.6": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "litellm_provider": "openrouter", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-4.6", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 5e-7, + "supports_prompt_caching": true + }, + "openrouter/x-ai/grok-build-0.1": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "source": "https://openrouter.ai/x-ai/grok-build-0.1", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_pdf_input": true, + "supports_audio_input": false, + "cache_read_input_token_cost": 2e-7, + "supports_prompt_caching": true + }, "openrouter/xiaomi/mimo-v2-flash": { "input_cost_per_token": 1e-7, "output_cost_per_token": 3e-7, @@ -35999,10 +40813,10 @@ "supports_prompt_caching": true }, "openrouter/xiaomi/mimo-v2.5": { - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 2.8e-7, "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 8e-8, + "cache_read_input_token_cost": 2.8e-9, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 131072, @@ -36018,10 +40832,10 @@ "supports_prompt_caching": true }, "openrouter/xiaomi/mimo-v2.5-pro": { - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000003, + "input_cost_per_token": 4.35e-7, + "output_cost_per_token": 8.7e-7, "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost": 3.6e-9, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 16384, @@ -36034,14 +40848,64 @@ "supports_response_schema": true, "supports_prompt_caching": true }, + "openrouter/z-ai/glm-4.5": { + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000022, + "cache_read_input_token_cost": 1.1e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 98304, + "max_tokens": 98304, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-4.5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/z-ai/glm-4.5-air": { + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 8.5e-7, + "cache_read_input_token_cost": 2.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 98304, + "max_tokens": 98304, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-4.5-air", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/z-ai/glm-4.5v": { + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000018, + "cache_read_input_token_cost": 1.1e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-4.5v", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, "openrouter/z-ai/glm-4.6": { - "input_cost_per_token": 4e-7, + "input_cost_per_token": 5.5e-7, "litellm_provider": "openrouter", "max_input_tokens": 202800, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", - "output_cost_per_token": 0.00000175, + "output_cost_per_token": 0.0000022, "source": "https://openrouter.ai/z-ai/glm-4.6", "supports_function_calling": true, "supports_prompt_caching": true, @@ -36062,11 +40926,28 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/z-ai/glm-4.6v": { + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": 5.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-4.6v", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, "openrouter/z-ai/glm-4.7": { "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.0000015, + "output_cost_per_token": 0.00000175, "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 0, + "cache_read_input_token_cost": 8e-8, "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 64000, @@ -36080,10 +40961,10 @@ "supports_assistant_prefill": true }, "openrouter/z-ai/glm-4.7-flash": { - "input_cost_per_token": 7e-8, + "input_cost_per_token": 6e-8, "output_cost_per_token": 4e-7, "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 0, + "cache_read_input_token_cost": 1e-8, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 32000, @@ -36096,22 +40977,39 @@ "supports_prompt_caching": false }, "openrouter/z-ai/glm-5": { - "input_cost_per_token": 8e-7, + "input_cost_per_token": 6e-7, "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.00000256, + "output_cost_per_token": 0.00000192, "source": "https://openrouter.ai/z-ai/glm-5", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/z-ai/glm-5-turbo": { + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 2.4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 202752, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5-turbo", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false, + "supports_prompt_caching": true + }, "openrouter/z-ai/glm-5.1": { - "input_cost_per_token": 0.00000105, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": 5.25e-7, + "input_cost_per_token": 9.66e-7, + "output_cost_per_token": 0.000003036, + "cache_read_input_token_cost": 1.794e-7, "cache_creation_input_token_cost": 0, "litellm_provider": "openrouter", "max_input_tokens": 202752, @@ -36124,6 +41022,91 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/z-ai/glm-5.2": { + "input_cost_per_token": 9.66e-7, + "output_cost_per_token": 0.000003036, + "cache_read_input_token_cost": 1.932e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5.2", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_parallel_function_calling": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/z-ai/glm-5.2:free": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 230400, + "max_tokens": 230400, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5.2:free", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": false + }, + "openrouter/z-ai/glm-5.3": { + "input_cost_per_token": 0.0000014, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 1.4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5.3", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_parallel_function_calling": true, + "supports_vision": false, + "supports_prompt_caching": true + }, + "openrouter/z-ai/glm-5.3-flash": { + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 2.5e-7, + "cache_read_input_token_cost": 1.5e-8, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5.3-flash", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, + "openrouter/z-ai/glm-5v-turbo": { + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 2.4e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 202752, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://openrouter.ai/z-ai/glm-5v-turbo", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_prompt_caching": true + }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-7, "litellm_provider": "ovhcloud", @@ -36791,7 +41774,7 @@ "supports_native_structured_output": true }, "qwen.qwen3-coder-480b-a35b-v1:0": { - "input_cost_per_token": 2.2e-7, + "input_cost_per_token": 4.5e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 262000, "max_output_tokens": 65536, @@ -36801,7 +41784,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/us-west-2/index.json" }, "qwen.qwen3-coder-next": { "input_cost_per_token": 5e-7, @@ -39391,6 +44375,34 @@ "output_cost_per_token": 0, "supports_reasoning": true }, + "scaleway/deepseek-v4-flash-0731": { + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 4e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-7, + "source": "https://www.scaleway.com/en/pricing/model-as-a-service/", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": false + }, + "scaleway/glm-5.2": { + "input_cost_per_token": 0.0000018, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://www.scaleway.com/en/pricing/model-as-a-service/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": false + }, "scaleway/google/gemma-3-27b-it": { "input_cost_per_token": 2.5e-7, "litellm_provider": "scaleway", @@ -40404,13 +45416,13 @@ "source": "https://docs.together.ai/docs/serverless-models" }, "together_ai/Qwen/Qwen3.8-2.4T-A95B": { - "cache_read_input_token_cost": 5e-7, - "input_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 2.5e-7, + "input_cost_per_token": 0.000002, "litellm_provider": "together_ai", "max_input_tokens": 1010000, "max_tokens": 1010000, "mode": "chat", - "output_cost_per_token": 0.00000625, + "output_cost_per_token": 0.000006, "source": "https://docs.together.ai/docs/serverless-models", "supports_prompt_caching": true }, @@ -40980,6 +45992,119 @@ "mode": "chat", "supports_video_input": true }, + "us-gov.anthropic.claude-3-haiku-20240307-v1:0": { + "deprecation_date": "2026-09-10", + "input_cost_per_token": 3e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "cache_read_input_token_cost": 3e-8, + "cache_creation_input_token_cost": 3.75e-7 + }, + "us-gov.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 0.000015, + "cache_creation_input_token_cost_above_1hr": 0.000024, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000012, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00006, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "us-gov.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "us-gov.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 0.0000075, + "cache_creation_input_token_cost_above_1hr": 0.000012, + "cache_read_input_token_cost": 6e-7, + "input_cost_per_token": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost_above_1hr": 0.0000072, @@ -41010,6 +46135,128 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "us-gov.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, + "bedrock_output_config_effort_ceiling": "xhigh", + "cache_creation_input_token_cost": 0.000003, + "cache_creation_input_token_cost_above_1hr": 0.0000048, + "cache_read_input_token_cost": 2.4e-7, + "input_cost_per_token": 0.0000024, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000012, + "prompt_cache_min_tokens": 1024, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "us-gov.nvidia.nemotron-nano-12b-v2": { + "input_cost_per_token": 2.4e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_system_messages": true, + "supports_vision": true + }, + "us-gov.nvidia.nemotron-nano-3-30b": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.88e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-8, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-7, + "supports_system_messages": true + }, + "us-gov.nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.8e-7, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.8e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 8.4e-8, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.6e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us-gov.xai.grok-4.6": { + "input_cost_per_token": 0.00000264, + "output_cost_per_token": 0.00000792, + "cache_read_input_token_cost": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "us.amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 8.25e-8, "input_cost_per_token": 3.3e-7, @@ -43437,7 +48684,8 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "deprecation_date": "2027-03-01" }, "vertex_ai/claude-fable-5-1@default": { "regional_endpoint_uplift_multiplier": 1.1, @@ -43473,7 +48721,8 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "deprecation_date": "2027-03-01" }, "vertex_ai/claude-fable-5@default": { "deprecation_date": "2027-06-08", @@ -44997,6 +50246,63 @@ "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014 }, + "vertex_ai/gemini-3.8-flash": { + "prompt_cache_min_tokens": 4096, + "cache_read_input_token_cost": 7.5e-8, + "cache_read_input_token_cost_flex": 3.75e-8, + "input_cost_per_token": 7.5e-7, + "input_cost_per_token_batches": 3.75e-7, + "input_cost_per_token_flex": 3.75e-7, + "litellm_provider": "vertex_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_reasoning_token": 0.00000375, + "output_cost_per_token": 0.00000375, + "output_cost_per_token_batches": 0.000001875, + "output_cost_per_token_flex": 0.000001875, + "regional_endpoint_uplift_multiplier": 1.1, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "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, + "supports_native_streaming": true, + "input_cost_per_token_priority": 0.00000135, + "output_cost_per_token_priority": 0.00000675, + "cache_read_input_token_cost_priority": 1.35e-7, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 + }, "vertex_ai/google/gemma-4-26b-a4b-it-maas": { "input_cost_per_token": 1.5e-7, "litellm_provider": "vertex_ai-openai_models", @@ -45798,8 +51104,8 @@ "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, - "input_cost_per_token": 0.01, - "output_cost_per_token": 0.01, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1e-7, "litellm_provider": "wandb", "mode": "chat" }, @@ -45807,8 +51113,8 @@ "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, - "input_cost_per_token": 0.01, - "output_cost_per_token": 0.01, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1e-7, "litellm_provider": "wandb", "mode": "chat" }, @@ -45880,8 +51186,8 @@ "max_tokens": 161000, "max_input_tokens": 161000, "max_output_tokens": 161000, - "input_cost_per_token": 0.135, - "output_cost_per_token": 0.54, + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, "litellm_provider": "wandb", "mode": "chat" }, @@ -45889,8 +51195,8 @@ "max_tokens": 161000, "max_input_tokens": 161000, "max_output_tokens": 161000, - "input_cost_per_token": 0.114, - "output_cost_per_token": 0.275, + "input_cost_per_token": 0.00000114, + "output_cost_per_token": 0.00000275, "litellm_provider": "wandb", "mode": "chat" }, @@ -45994,8 +51300,8 @@ "max_tokens": 64000, "max_input_tokens": 64000, "max_output_tokens": 64000, - "input_cost_per_token": 0.017, - "output_cost_per_token": 0.066, + "input_cost_per_token": 1.7e-7, + "output_cost_per_token": 6.6e-7, "litellm_provider": "wandb", "mode": "chat" }, @@ -46121,17 +51427,31 @@ "supports_vision": false, "source": "https://wandb.ai/site/pricing/tokens/" }, + "watsonx/bigscience/mt0-xxl": { + "max_tokens": 4096, + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "input_cost_per_token": 0.000001908, + "output_cost_per_token": 0.000001908, + "litellm_provider": "watsonx", + "mode": "chat", + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" + }, "watsonx/bigscience/mt0-xxl-13b": { - "max_tokens": 8192, - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "input_cost_per_token": 0.0005, - "output_cost_per_token": 0.002, + "max_tokens": 4096, + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "input_cost_per_token": 0.000001908, + "output_cost_per_token": 0.000001908, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/core42/jais-13b-chat": { "max_tokens": 8192, @@ -46212,16 +51532,17 @@ "supports_vision": false }, "watsonx/ibm/granite-4-h-small": { - "max_tokens": 20480, - "max_input_tokens": 20480, - "max_output_tokens": 20480, - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2.5e-7, + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 6.36e-8, + "output_cost_per_token": 2.65e-7, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/ibm/granite-guardian-3-2-2b": { "max_tokens": 8192, @@ -46344,28 +51665,43 @@ "supports_vision": true }, "watsonx/meta-llama/llama-3-3-70b-instruct": { - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 7.1e-7, - "output_cost_per_token": 7.1e-7, + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 7.526e-7, + "output_cost_per_token": 7.526e-7, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/meta-llama/llama-4-maverick-17b": { - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 0.0000014, + "max_tokens": 8192, + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "input_cost_per_token": 3.71e-7, + "output_cost_per_token": 0.000001484, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" + }, + "watsonx/meta-llama/llama-4-maverick-17b-128e-instruct-fp8": { + "max_tokens": 8192, + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "input_cost_per_token": 3.71e-7, + "output_cost_per_token": 0.000001484, + "litellm_provider": "watsonx", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/meta-llama/llama-guard-3-11b-vision": { "max_tokens": 128000, @@ -46422,16 +51758,17 @@ "supports_vision": false }, "watsonx/mistralai/mistral-small-3-1-24b-instruct-2503": { - "max_tokens": 32000, - "max_input_tokens": 32000, - "max_output_tokens": 32000, - "input_cost_per_token": 1e-7, - "output_cost_per_token": 3e-7, + "max_tokens": 16384, + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "input_cost_per_token": 1.06e-7, + "output_cost_per_token": 3.18e-7, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/mistralai/pixtral-12b-2409": { "max_tokens": 128000, @@ -46446,16 +51783,17 @@ "supports_vision": true }, "watsonx/openai/gpt-oss-120b": { - "max_tokens": 8192, - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.59e-7, + "output_cost_per_token": 6.36e-7, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, - "supports_vision": false + "supports_vision": false, + "source": "https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models.html?context=wx" }, "watsonx/sdaia/allam-1-13b-instruct": { "max_tokens": 8192, @@ -47369,6 +52707,27 @@ "supports_response_schema": true, "supports_vision": true }, + "xai/grok-build-latest": { + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000002, + "input_cost_per_token_above_200k_tokens": 0.000004, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.000012, + "source": "https://docs.x.ai/developers/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-code-fast": { "cache_read_input_token_cost": 2e-7, "input_cost_per_token": 0.000001, diff --git a/cecli/sessions.py b/cecli/sessions.py index a8e178ec04e..01a2d0b3a0b 100644 --- a/cecli/sessions.py +++ b/cecli/sessions.py @@ -381,6 +381,9 @@ async def _apply_session_data( self.coder.total_tokens_received = usage.get("total_tokens_received", 0) self.coder.total_cached_tokens = usage.get("total_cached_tokens", 0) self.coder.total_cost = usage.get("total_cost", 0.0) + # Loading a session seeds the cumulative counters but does not represent + # recent API usage, so clear the rolling token-rate buffer. + self.coder._reset_token_usage() if session_data.get("model"): self.coder.main_model = models.Model( session_data.get("model", self.coder.args.model), diff --git a/cecli/tests/test_queue_commands.py b/cecli/tests/test_queue_commands.py new file mode 100644 index 00000000000..e0fc306d898 --- /dev/null +++ b/cecli/tests/test_queue_commands.py @@ -0,0 +1,792 @@ +""" +Test suite for CLI-33 Queue Commands. + +This module contains comprehensive tests for: +- Unit tests: Queue logic in Commands class (core.py) +- Integration tests: QueueCommand, ListQueueCommand, RemoveQueueCommand +- E2E tests: Full queue lifecycle and processing +- Regression tests: Existing command integrity + +Test categories: +- UTC-01 through UTC-20: Unit tests for queue methods +- ITC-01 through ITC-20: Integration tests for commands +- ETC-01 through ETC-10: E2E tests for full lifecycle +- RTC-01 through RTC-05: Regression tests for existing functionality +- TDS-01 through TDS-04: Test data setup and fixtures +""" + +import asyncio +import time +from unittest.mock import MagicMock + +import pytest + +# Import the actual classes to test +from cecli.commands.core import Commands +from cecli.commands.list_queue import ListQueueCommand +from cecli.commands.queue import QueueCommand +from cecli.commands.remove_queue import RemoveQueueCommand +from cecli.commands.utils.registry import CommandRegistry +from cecli.signals import ReloadProgramSignal, SwitchCoderSignal + + +def _make_coder(): + """Build a minimal coder-like object with the queue attributes the + ``command_queue`` helpers require (``prompt_queue``, ``_queue_counter``, + ``_queue_lock``).""" + coder = MagicMock() + import uuid + + coder.uuid = str(uuid.uuid4()) + coder.prompt_queue = [] + coder._queue_counter = 0 + return coder + + +@pytest.fixture(autouse=True) +def _reset_agent_service(): + """Reset the AgentService singleton between tests. + + ``command_queue.get_active_coder`` resolves the foreground coder through + ``AgentService``, whose ``_instances`` and ``_primary_agent_uuid`` are + class-level state. Without a reset, the first test's coder becomes the + "primary" and every later test is routed to that coder's queue, causing + cross-test pollution. + """ + from cecli.helpers.agents.service import AgentService + + AgentService._instances = {} + AgentService._primary_agent_uuid = None + yield + AgentService._instances = {} + AgentService._primary_agent_uuid = None + + +# ============================================================================ +# Test Fixtures (Section 10.6, TDS-01 through TDS-04) +# ============================================================================ + + +@pytest.fixture +def mock_io(): + """Create a mock IO object with tool_output, tool_error, tool_warning methods.""" + io = MagicMock() + io.tool_output = MagicMock() + io.tool_error = MagicMock() + io.tool_warning = MagicMock() + return io + + +@pytest.fixture +def mock_coder(): + """Create a mock coder with commands attribute pointing to a Commands instance.""" + coder = _make_coder() + commands = Commands(io=None, coder=coder) + coder.commands = commands + coder.io = None + coder.tui = None + return coder + + +@pytest.fixture +def clean_commands(): + """Create a fresh Commands instance with empty queue for isolated testing.""" + return Commands(io=None, coder=_make_coder()) + + +@pytest.fixture +def populated_queue(clean_commands): + """Create Commands with pre-populated queue with known items.""" + clean_commands._enqueue_prompt("alpha") + clean_commands._enqueue_prompt("beta") + clean_commands._enqueue_prompt("gamma") + return clean_commands + + +@pytest.fixture +def full_queue(): + """Create Commands with queue filled to max capacity (100 items).""" + commands = Commands(io=None, coder=_make_coder()) + for i in range(100): + commands._enqueue_prompt(f"prompt_{i}") + return commands + + +@pytest.fixture +def mock_coder_no_commands(): + """Create a mock coder with commands set to None.""" + coder = MagicMock() + coder.commands = None + coder.io = None + coder.tui = None + return coder + + +# ============================================================================ +# Unit Tests - Queue Logic (Section 10.2, UTC-01 through UTC-20) +# ============================================================================ + + +class TestEnqueuePrompt: + """Unit tests for _enqueue_prompt method.""" + + def test_utc_01_enqueue_single_prompt(self, clean_commands): + """UTC-01: Enqueue single prompt adds one item with correct structure.""" + item = clean_commands._enqueue_prompt("test prompt") + + assert len(clean_commands.prompt_queue) == 1 + assert item["text"] == "test prompt" + assert "id" in item + assert "timestamp" in item + assert isinstance(item["id"], str) + assert isinstance(item["timestamp"], float) + + def test_utc_02_enqueue_multiple_prompts_fifo_order(self, clean_commands): + """UTC-02: Enqueue multiple prompts maintains FIFO order and unique IDs.""" + item1 = clean_commands._enqueue_prompt("first") + item2 = clean_commands._enqueue_prompt("second") + item3 = clean_commands._enqueue_prompt("third") + + assert len(clean_commands.prompt_queue) == 3 + assert clean_commands.prompt_queue[0]["text"] == "first" + assert clean_commands.prompt_queue[1]["text"] == "second" + assert clean_commands.prompt_queue[2]["text"] == "third" + assert item1["id"] != item2["id"] + assert item2["id"] != item3["id"] + + def test_utc_13_max_queue_size_rejection(self, clean_commands): + """UTC-13: Enqueue rejected when queue already contains 100 items.""" + for i in range(100): + clean_commands._enqueue_prompt(f"prompt_{i}") + + with pytest.raises(RuntimeError, match="Queue is full"): + clean_commands._enqueue_prompt("overflow") + + def test_utc_14_enqueue_empty_string_rejected(self, clean_commands): + """UTC-14: Enqueue rejects empty string with ValueError.""" + with pytest.raises(ValueError, match="Cannot enqueue empty prompt"): + clean_commands._enqueue_prompt("") + + def test_utc_14_enqueue_none_rejected(self, clean_commands): + """UTC-14: Enqueue rejects None with ValueError.""" + with pytest.raises(ValueError, match="Cannot enqueue empty prompt"): + clean_commands._enqueue_prompt(None) + + def test_utc_15_enqueue_extremely_long_prompt_rejected(self, clean_commands): + """UTC-15: Enqueue rejects prompt exceeding 10,000 characters.""" + long_prompt = "x" * 10001 + with pytest.raises(ValueError, match="exceeds maximum length"): + clean_commands._enqueue_prompt(long_prompt) + + def test_utc_16_enqueue_exactly_10000_chars_accepted(self, clean_commands): + """UTC-16: Enqueue accepts prompt of exactly 10,000 characters (boundary).""" + boundary_prompt = "x" * 10000 + item = clean_commands._enqueue_prompt(boundary_prompt) + assert item["text"] == boundary_prompt + assert len(clean_commands.prompt_queue) == 1 + + def test_utc_17_enqueue_9999_chars_accepted(self, clean_commands): + """UTC-17: Enqueue accepts prompt of 9,999 characters (boundary).""" + boundary_prompt = "x" * 9999 + item = clean_commands._enqueue_prompt(boundary_prompt) + assert item["text"] == boundary_prompt + assert len(clean_commands.prompt_queue) == 1 + + def test_utc_18_counter_persistence(self, clean_commands): + """UTC-18: Internal counter increments across enqueue/remove cycles without reset.""" + clean_commands._enqueue_prompt("first") + clean_commands._enqueue_prompt("second") + item = clean_commands._enqueue_prompt("third") + + assert clean_commands._queue_counter == 3 + assert item["id"] == "3" + + +class TestDequeuePrompt: + """Unit tests for _dequeue_prompt method.""" + + def test_utc_03_dequeue_from_empty_queue(self, clean_commands): + """UTC-03: Dequeue from empty queue returns None without side effects.""" + result = clean_commands._dequeue_prompt() + assert result is None + assert len(clean_commands.prompt_queue) == 0 + + def test_utc_04_dequeue_returns_fifo_first_item(self, clean_commands): + """UTC-04: Dequeue returns first item and shrinks queue by one.""" + clean_commands._enqueue_prompt("first") + clean_commands._enqueue_prompt("second") + + item = clean_commands._dequeue_prompt() + assert item["text"] == "first" + assert len(clean_commands.prompt_queue) == 1 + assert clean_commands.prompt_queue[0]["text"] == "second" + + def test_utc_05_dequeue_until_empty(self, clean_commands): + """UTC-05: Repeated dequeue eventually returns None after queue empties.""" + clean_commands._enqueue_prompt("only_item") + + item = clean_commands._dequeue_prompt() + assert item is not None + assert item["text"] == "only_item" + + result = clean_commands._dequeue_prompt() + assert result is None + + +class TestGetQueueLength: + """Unit tests for _get_queue_length method.""" + + def test_utc_06_queue_length_empty(self, clean_commands): + """UTC-06: Queue length returns correct count for empty queue.""" + assert clean_commands._get_queue_length() == 0 + + def test_utc_07_queue_length_non_empty(self, clean_commands): + """UTC-07: Queue length returns correct count for populated queue.""" + clean_commands._enqueue_prompt("item1") + clean_commands._enqueue_prompt("item2") + clean_commands._enqueue_prompt("item3") + + assert clean_commands._get_queue_length() == 3 + + +class TestRemoveFromQueue: + """Unit tests for _remove_from_queue method.""" + + def test_utc_08_remove_by_valid_index(self, clean_commands): + """UTC-08: Remove by valid index returns item and shrinks queue by one.""" + clean_commands._enqueue_prompt("first") + clean_commands._enqueue_prompt("second") + clean_commands._enqueue_prompt("third") + + item = clean_commands._remove_from_queue(1) + assert item["text"] == "second" + assert len(clean_commands.prompt_queue) == 2 + assert clean_commands.prompt_queue[0]["text"] == "first" + assert clean_commands.prompt_queue[1]["text"] == "third" + + def test_utc_09_remove_out_of_bounds_high_index(self, clean_commands): + """UTC-09: Remove by out-of-bounds high index returns None without mutation.""" + clean_commands._enqueue_prompt("only_item") + + result = clean_commands._remove_from_queue(5) + assert result is None + assert len(clean_commands.prompt_queue) == 1 + + def test_utc_10_remove_negative_index(self, clean_commands): + """UTC-10: Remove by negative index returns None without mutation.""" + clean_commands._enqueue_prompt("only_item") + + result = clean_commands._remove_from_queue(-1) + assert result is None + assert len(clean_commands.prompt_queue) == 1 + + +class TestClearQueue: + """Unit tests for _clear_queue method.""" + + def test_utc_11_clear_queue_with_items(self, clean_commands): + """UTC-11: Clear queue returns all items and empties queue.""" + clean_commands._enqueue_prompt("item1") + clean_commands._enqueue_prompt("item2") + clean_commands._enqueue_prompt("item3") + + items = clean_commands._clear_queue() + assert len(items) == 3 + assert len(clean_commands.prompt_queue) == 0 + + def test_utc_12_clear_empty_queue(self, clean_commands): + """UTC-12: Clear empty queue returns empty list and remains empty.""" + items = clean_commands._clear_queue() + assert items == [] + assert len(clean_commands.prompt_queue) == 0 + + +class TestTimestampBehavior: + """Unit tests for timestamp generation.""" + + def test_utc_19_timestamps_monotonic(self, clean_commands): + """UTC-19: Timestamps are monotonic non-decreasing across enqueues.""" + item1 = clean_commands._enqueue_prompt("first") + time.sleep(0.01) + item2 = clean_commands._enqueue_prompt("second") + + assert item1["timestamp"] <= item2["timestamp"] + + +# ============================================================================ +# Integration Tests - Command Classes (Section 10.3, ITC-01 through ITC-20) +# ============================================================================ + + +class TestQueueCommand: + """Integration tests for QueueCommand.""" + + @pytest.mark.asyncio + async def test_itc_01_queue_enqueues_and_confirms_position(self, mock_io, mock_coder): + """ITC-01: /queue "prompt" enqueues and confirms queue position.""" + result = await QueueCommand.execute(mock_io, mock_coder, "test prompt") + + assert result == "Successfully executed queue." + assert len(mock_coder.commands.prompt_queue) == 1 + mock_io.tool_output.assert_called() + + @pytest.mark.asyncio + async def test_itc_02_queue_empty_args_shows_usage(self, mock_io, mock_coder): + """ITC-02: /queue with empty args shows usage/help and does not enqueue.""" + result = await QueueCommand.execute(mock_io, mock_coder, "") + + assert "Error" in result + assert len(mock_coder.commands.prompt_queue) == 0 + + @pytest.mark.asyncio + async def test_itc_03_queue_no_args_shows_usage(self, mock_io, mock_coder): + """ITC-03: /queue with no args shows usage/help and does not enqueue.""" + result = await QueueCommand.execute(mock_io, mock_coder, None) + + assert "Error" in result + assert len(mock_coder.commands.prompt_queue) == 0 + + @pytest.mark.asyncio + async def test_itc_04_queue_rejects_long_prompt(self, mock_io, mock_coder): + """ITC-04: /queue rejects prompt >10,000 characters with warning.""" + long_prompt = "x" * 10001 + result = await QueueCommand.execute(mock_io, mock_coder, long_prompt) + + assert "Error" in result or "exceeds" in result.lower() + assert len(mock_coder.commands.prompt_queue) == 0 + + @pytest.mark.asyncio + async def test_itc_05_queue_handles_coder_commands_none(self, mock_io, mock_coder_no_commands): + """ITC-05: /queue handles coder.commands is None with error message.""" + result = await QueueCommand.execute(mock_io, mock_coder_no_commands, "test") + + assert "Error" in result or "not available" in result.lower() + + @pytest.mark.asyncio + async def test_itc_06_queue_at_max_capacity_rejects(self, mock_io, full_queue): + """ITC-06: /queue at max capacity (100) rejects new prompt.""" + mock_coder = MagicMock() + mock_coder.commands = full_queue + mock_coder.io = mock_io + + result = await QueueCommand.execute(mock_io, mock_coder, "overflow") + + assert "Error" in result or "full" in result.lower() + + +class TestListQueueCommand: + """Integration tests for ListQueueCommand.""" + + @pytest.mark.asyncio + async def test_itc_07_list_queue_shows_numbered_list(self, mock_io, populated_queue): + """ITC-07: /list-queue displays numbered list of queued prompts with timestamps.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await ListQueueCommand.execute(mock_io, mock_coder, "") + + assert result == "Successfully executed list-queue." + mock_io.tool_output.assert_called() + calls = [str(call) for call in mock_io.tool_output.call_args_list] + output_text = " ".join(calls) + assert "[1]" in output_text or "alpha" in output_text + + @pytest.mark.asyncio + async def test_itc_08_list_queue_empty_shows_message(self, mock_io, clean_commands): + """ITC-08: /list-queue on empty queue shows "Queue is empty" message.""" + mock_coder = MagicMock() + mock_coder.commands = clean_commands + mock_coder.io = mock_io + + result = await ListQueueCommand.execute(mock_io, mock_coder, "") + + assert result == "Successfully executed list-queue." + mock_io.tool_output.assert_called() + + @pytest.mark.asyncio + async def test_itc_09_list_queue_handles_coder_commands_none( + self, mock_io, mock_coder_no_commands + ): + """ITC-09: /list-queue handles coder.commands is None with error message.""" + result = await ListQueueCommand.execute(mock_io, mock_coder_no_commands, "") + + assert "Error" in result or "not available" in result.lower() + + @pytest.mark.asyncio + async def test_itc_10_list_queue_truncates_long_prompts(self, mock_io): + """ITC-10: /list-queue truncates prompts longer than display threshold.""" + commands = Commands(io=None, coder=None) + long_prompt = "x" * 120 + commands._enqueue_prompt(long_prompt) + + mock_coder = MagicMock() + mock_coder.commands = commands + mock_coder.io = mock_io + + await ListQueueCommand.execute(mock_io, mock_coder, "") + + calls = [str(call) for call in mock_io.tool_output.call_args_list] + output_text = " ".join(calls) + assert "..." in output_text or "x" * 80 in output_text + + +class TestRemoveQueueCommand: + """Integration tests for RemoveQueueCommand.""" + + @pytest.mark.asyncio + async def test_itc_11_remove_by_index(self, mock_io, populated_queue): + """ITC-11: /remove-queue removes exact item and confirms removal.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "2") + + assert result == "Successfully executed remove-queue." + assert len(populated_queue.prompt_queue) == 2 + mock_io.tool_output.assert_called() + + @pytest.mark.asyncio + async def test_itc_12_remove_wildcard_clears_all(self, mock_io, populated_queue): + """ITC-12: /remove-queue * clears entire queue and confirms count removed.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "*") + + assert result == "Successfully executed remove-queue." + assert len(populated_queue.prompt_queue) == 0 + mock_io.tool_output.assert_called() + + @pytest.mark.asyncio + async def test_itc_13_remove_interactive_mode(self, mock_io, populated_queue): + """ITC-13: /remove-queue with no args enters interactive selection.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "") + + # Interactive mode shows queue list and prompt, returns success status + assert result == "Successfully executed remove-queue." + mock_io.tool_output.assert_called() + calls = [str(call) for call in mock_io.tool_output.call_args_list] + output_text = " ".join(calls) + assert "Queued prompts:" in output_text or "Enter index" in output_text + + @pytest.mark.asyncio + async def test_itc_14_remove_invalid_index_non_integer(self, mock_io, populated_queue): + """ITC-14: /remove-queue with non-integer index shows invalid index error.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "abc") + + assert "Error" in result or "Invalid index" in result + + @pytest.mark.asyncio + async def test_itc_15_remove_out_of_bounds_index(self, mock_io, populated_queue): + """ITC-15: /remove-queue with out-of-bounds index shows error.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "99") + + assert "Error" in result or "out of range" in result.lower() + + @pytest.mark.asyncio + async def test_itc_16_remove_negative_index(self, mock_io, populated_queue): + """ITC-16: /remove-queue with negative index shows error.""" + mock_coder = MagicMock() + mock_coder.commands = populated_queue + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "-1") + + assert "Error" in result or "Invalid index" in result + + @pytest.mark.asyncio + async def test_itc_17_remove_empty_queue(self, mock_io, clean_commands): + """ITC-17: /remove-queue on empty queue shows error.""" + mock_coder = MagicMock() + mock_coder.commands = clean_commands + mock_coder.io = mock_io + + result = await RemoveQueueCommand.execute(mock_io, mock_coder, "1") + + assert "Error" in result or "empty" in result.lower() + + @pytest.mark.asyncio + async def test_itc_18_remove_handles_coder_commands_none(self, mock_io, mock_coder_no_commands): + """ITC-18: /remove-queue handles coder.commands is None.""" + result = await RemoveQueueCommand.execute(mock_io, mock_coder_no_commands, "1") + + assert "Error" in result or "not available" in result.lower() + + def test_itc_19_get_completions_returns_indices_and_wildcard(self, mock_coder, populated_queue): + """ITC-19: RemoveQueueCommand.get_completions() returns valid index completions and '*'.""" + mock_coder.commands = populated_queue + completions = RemoveQueueCommand.get_completions(None, mock_coder, "") + + assert "1" in completions + assert "2" in completions + assert "3" in completions + assert "*" in completions + + def test_itc_20_commands_registered_in_registry(self): + """ITC-20: All three commands are registered and discoverable via help/registry lookup.""" + assert CommandRegistry.get_command("queue") is not None + assert CommandRegistry.get_command("list-queue") is not None + assert CommandRegistry.get_command("remove-queue") is not None + + +# ============================================================================ +# E2E Tests - Full Queue Lifecycle (Section 10.4, ETC-01 through ETC-10) +# ============================================================================ + + +class TestQueueLifecycle: + """E2E tests for full queue lifecycle and processing.""" + + @pytest.mark.asyncio + async def test_etc_01_single_queued_prompt_auto_processes(self, mock_io, populated_queue): + """ETC-01: Single queued prompt auto-processes after system becomes idle.""" + assert hasattr(populated_queue, "_process_queued_prompts") + assert callable(populated_queue._process_queued_prompts) + + @pytest.mark.asyncio + async def test_etc_02_multiple_prompts_fifo_order(self, clean_commands): + """ETC-02: Multiple queued prompts execute in FIFO order with no reordering.""" + clean_commands._enqueue_prompt("prompt_A") + clean_commands._enqueue_prompt("prompt_B") + clean_commands._enqueue_prompt("prompt_C") + + assert clean_commands.prompt_queue[0]["text"] == "prompt_A" + assert clean_commands.prompt_queue[1]["text"] == "prompt_B" + assert clean_commands.prompt_queue[2]["text"] == "prompt_C" + + @pytest.mark.asyncio + async def test_etc_03_queued_prompt_not_processed_while_running(self, mock_io, populated_queue): + """ETC-03: Queued prompt is not processed while another command is running.""" + populated_queue.cmd_running_event.clear() + + assert hasattr(populated_queue, "_MANAGEMENT_COMMANDS") + assert "queue" in populated_queue._MANAGEMENT_COMMANDS + + @pytest.mark.asyncio + async def test_etc_06_management_commands_dont_trigger_processing( + self, mock_io, populated_queue + ): + """ETC-06: Management commands do not trigger auto-processing of queued items.""" + assert populated_queue._MANAGEMENT_COMMANDS == {"queue", "list-queue", "remove-queue"} + + @pytest.mark.asyncio + async def test_etc_05_prevent_infinite_loop(self, clean_commands): + """ETC-05: Queued command that queues additional items does not cause infinite loop.""" + assert hasattr(clean_commands, "_processing_queue") + assert clean_commands._processing_queue is False + + @pytest.mark.asyncio + async def test_etc_09_error_in_queued_prompt_continues(self, clean_commands): + """ETC-09: Exception in queued prompt is logged but doesn't stop later items.""" + assert hasattr(clean_commands, "_process_queued_prompts") + + @pytest.mark.asyncio + async def test_etc_10_full_lifecycle_sequence(self, mock_io, populated_queue): + """ETC-10: Full lifecycle sequence add -> list -> remove -> process.""" + item = populated_queue._enqueue_prompt("new_prompt") + assert item is not None + + assert populated_queue._get_queue_length() == 4 + + removed = populated_queue._remove_from_queue(0) + assert removed is not None + + assert populated_queue._get_queue_length() == 3 + + +# ============================================================================ +# Regression Tests - Existing Command Integrity (Section 10.5, RTC-01 through RTC-05) +# ============================================================================ + + +class TestRegression: + """Regression tests to ensure existing functionality is not broken.""" + + def test_rtc_01_existing_commands_still_registered(self): + """RTC-01: Existing commands still registered and functional after queue commands added.""" + assert CommandRegistry.get_command("help") is not None + assert CommandRegistry.get_command("run") is not None + assert CommandRegistry.get_command("model") is not None + + def test_rtc_02_commands_init_preserves_existing_attributes(self, clean_commands): + """RTC-02: Commands.__init__ preserves pre-existing attributes and adds new queue fields.""" + assert hasattr(clean_commands, "io") + assert hasattr(clean_commands, "coder") + assert hasattr(clean_commands, "cmd_running_event") + assert hasattr(clean_commands, "last_command_show_notification") + + assert hasattr(clean_commands, "prompt_queue") + assert hasattr(clean_commands, "_queue_counter") + assert hasattr(clean_commands, "_queue_lock") + assert hasattr(clean_commands, "_processing_queue") + assert hasattr(clean_commands, "_MANAGEMENT_COMMANDS") + + def test_rtc_03_execute_preserves_existing_flow(self, mock_io, mock_coder): + """RTC-03: Commands.execute() preserves existing command behavior for non-queue commands.""" + assert hasattr(mock_coder.commands, "execute") + assert callable(mock_coder.commands.execute) + + def test_rtc_04_init_py_imports_all_commands(self): + """RTC-04: cecli/commands/__init__.py imports all commands without conflicts.""" + from cecli.commands import ( + CommandRegistry, + ListQueueCommand, + QueueCommand, + RemoveQueueCommand, + ) + + assert CommandRegistry.get_command("queue") is QueueCommand + assert CommandRegistry.get_command("list-queue") is ListQueueCommand + assert CommandRegistry.get_command("remove-queue") is RemoveQueueCommand + + def test_rtc_05_thread_safety_under_concurrent_access(self, clean_commands): + """RTC-05: Simulated concurrent access patterns do not corrupt queue state.""" + assert hasattr(clean_commands, "_queue_lock") + assert isinstance(clean_commands._queue_lock, asyncio.Lock) + + +# ============================================================================ +# Additional Tests for Edge Cases and Error Handling +# ============================================================================ + + +class TestEdgeCases: + """Additional edge case tests.""" + + def test_queue_with_whitespace_only_prompt(self, clean_commands): + """Test that whitespace-only prompts are rejected.""" + with pytest.raises(ValueError): + clean_commands._enqueue_prompt(" ") + + def test_queue_with_unicode_prompt(self, clean_commands): + """Test that unicode prompts are handled correctly.""" + item = clean_commands._enqueue_prompt("Hello world") + assert item["text"] == "Hello world" + + def test_remove_with_zero_index(self, clean_commands): + """Test removing with index 0 (first item).""" + clean_commands._enqueue_prompt("first") + clean_commands._enqueue_prompt("second") + + item = clean_commands._remove_from_queue(0) + assert item["text"] == "first" + + def test_remove_with_large_index(self, clean_commands): + """Test removing with a very large index.""" + clean_commands._enqueue_prompt("only") + + result = clean_commands._remove_from_queue(999999) + assert result is None + + +# ============================================================================ +# Command Registration Tests +# ============================================================================ + + +class TestCommandRegistration: + """Tests for command registration in CommandRegistry.""" + + def test_all_queue_commands_registered(self): + """Verify all queue commands are properly registered.""" + commands = CommandRegistry.list_commands() + + assert "queue" in commands + assert "list-queue" in commands + assert "remove-queue" in commands + + def test_command_classes_have_required_attributes(self): + """Verify command classes have NORM_NAME and DESCRIPTION.""" + assert QueueCommand.NORM_NAME == "queue" + assert QueueCommand.DESCRIPTION is not None + + assert ListQueueCommand.NORM_NAME == "list-queue" + assert ListQueueCommand.DESCRIPTION is not None + + assert RemoveQueueCommand.NORM_NAME == "remove-queue" + assert RemoveQueueCommand.DESCRIPTION is not None + + def test_command_classes_have_execute_method(self): + """Verify command classes have async execute method.""" + assert hasattr(QueueCommand, "execute") + assert hasattr(ListQueueCommand, "execute") + assert hasattr(RemoveQueueCommand, "execute") + + assert callable(QueueCommand.execute) + assert callable(ListQueueCommand.execute) + assert callable(RemoveQueueCommand.execute) + + +# ============================================================================ +# Helper Function Tests +# ============================================================================ + + +class TestHelpers: + """Tests for helper functions used by queue commands.""" + + def test_format_command_result_success(self, mock_io): + """Test format_command_result for successful execution.""" + from cecli.commands.utils.helpers import format_command_result + + result = format_command_result(mock_io, "test", "Success message") + assert result == "Successfully executed test." + mock_io.tool_output.assert_called_once() + + def test_format_command_result_error(self, mock_io): + """Test format_command_result for error case.""" + from cecli.commands.utils.helpers import format_command_result + + result = format_command_result(mock_io, "test", "Success", error="Something went wrong") + assert "Error" in result + mock_io.tool_error.assert_called_once() + + +# ============================================================================ +# Signal Tests +# ============================================================================ + + +class TestSignals: + """Tests for custom signals used in queue processing.""" + + def test_switch_coder_signal_attributes(self): + """Test SwitchCoderSignal has expected attributes.""" + signal = SwitchCoderSignal(placeholder="test", custom_arg="value") + + assert signal.placeholder == "test" + assert signal.kwargs == {"custom_arg": "value"} + + def test_reload_program_signal_message(self): + """Test ReloadProgramSignal has message attribute.""" + signal = ReloadProgramSignal(message="Custom message") + + assert signal.message == "Custom message" + + +# ============================================================================ +# Async Test Configuration +# ============================================================================ + +# Note: For pytest-asyncio to work, you may need to add to pyproject.toml or pytest.ini: +# [pytest] +# asyncio_mode = auto +# asyncio_default_fixture_loop_scope = function + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 2efe5d5f4d6..38262cb6c72 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -36,7 +36,7 @@ class Tool(BaseTool): "wait": { "type": "integer", "description": ( - "Optional time in seconds (between 15 and 120) to wait " + "Optional time in seconds (between 15 and 300, default 60) to wait " "before returning. When provided, the tool simply sleeps " "for the specified duration and returns, " "allowing for other tasks to proceed." @@ -65,14 +65,14 @@ async def execute(cls, coder, **kwargs): wait = kwargs.get("wait") if wait is not None and wait != "": if isinstance(wait, bool): - wait_seconds = 30 + wait_seconds = 60 else: try: wait_seconds = int(wait) except (ValueError, TypeError): - wait_seconds = 30 + wait_seconds = 60 - wait_seconds = max(15, min(120, wait_seconds)) + wait_seconds = max(15, min(300, wait_seconds)) # Plain wait — sleep for the requested duration without checking # sub-agents or marking the task as finished. diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 828eea3dfcc..6fd9c3be2aa 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -428,39 +428,36 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Non response.append_result("Command execution interrupted by user.") return response - if wait_task in done: - # Process completed - exit_code = wait_task.result() - output = buffer.get_all(clear=True) - - # Format output - output_content = output or "" - # Tokens are roughly 3-4 characters - output_limit = int(coder.large_file_token_threshold * 3.5) - - if coder.context_management_enabled and len(output_content) > output_limit * 1.25: - # Save full output to paginated files instead of truncating - folder_path, file_list, alias_paths = ( - BackgroundCommandManager.save_paginated_output( - output=output_content, - command_key=command_key, - page_size=output_limit, - abs_root_path_func=coder.abs_root_path, - local_agent_folder_func=coder.local_agent_folder, - ) - ) - # Build a summary with full file list - total_size = len(output_content) - alias_list_str = "\n".join(f" - {a}" for a in alias_paths) - output_content = ( - f"[Large Response ({total_size} characters). " - "Output saved to paginated files.]\n" - f"File Aliases (for use with ResourceManager):\n{alias_list_str}\n" - "Use the `ResourceManager` tool to view these files." - "Do not use standard cli tools to view these files." - "Remove them from context after taking notes on the relevant information " - "to prevent overfilling stale context." + command_completed = wait_task in done + output_content = buffer.get_all(clear=command_completed) or "" + # Tokens are roughly 3-4 characters + output_limit = int(coder.large_file_token_threshold * 3.5) + + if coder.context_management_enabled and len(output_content) > output_limit * 1.25: + folder_path, file_list, alias_paths = ( + BackgroundCommandManager.save_paginated_output( + output=output_content, + command_key=command_key, + page_size=output_limit, + abs_root_path_func=coder.abs_root_path, + local_agent_folder_func=coder.local_agent_folder, ) + ) + total_size = len(output_content) + output_content = ( + f"[Large Response ({total_size} characters). " + f"Output saved in {len(file_list)} pages.]\n" + f"Command key: {command_key}\n" + f"Pages: 1-{len(file_list)}\n" + "Use `ResourceManager` to view up to 3 pages at a time:\n" + f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}\n' + "Change page or add entries to read other pages (maximum 3 entries). " + "Do not use add, read_only, or standard CLI tools to view command output " + "files. Pages are returned directly, not added to file context." + ) + + if command_completed: + exit_code = wait_task.result() # Remove from background tracking since it's done BackgroundCommandManager.stop_background_command(command_key) @@ -488,13 +485,10 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Non type="tool-result", ) - # Get any output captured so far - current_output = buffer.get_all(clear=False) - response.append_result( f"Command exceeded {timeout}s timeout and is continuing in background.\n" f"Command key: {command_key}\n" - f"Output captured so far:\n{current_output}\n" + f"Output captured so far:\n{output_content}\n" ) return response finally: @@ -543,17 +537,17 @@ async def _execute_foreground(cls, coder, command_string): abs_root_path_func=coder.abs_root_path, local_agent_folder_func=coder.local_agent_folder, ) - # Build a summary with full file list total_size = len(output_content) - alias_list_str = "\n".join(f" - {a}" for a in alias_paths) output_content = ( f"[Large Response ({total_size} characters). " - "Output saved to paginated files.]\n" - f"File Aliases (for use with ResourceManager):\n{alias_list_str}\n" - "Use the `ResourceManager` tool to view these files." - "Do not use standard cli tools to view these files." - "Remove them from context after taking note of the relevant information " - "in the output to prevent overfilling stale context." + f"Output saved in {len(file_list)} pages.]\n" + f"Command key: {fg_key}\n" + f"Pages: 1-{len(file_list)}\n" + "Use `ResourceManager` to view up to 3 pages at a time:\n" + f'{{"paging": [{{"target": "{fg_key}", "page": 1}}]}}\n' + "Change page or add entries to read other pages (maximum 3 entries). " + "Do not use add, read_only, or standard CLI tools to view command output " + "files. Pages are returned directly, not added to file context." ) if tui: diff --git a/cecli/tools/resource_manager.py b/cecli/tools/resource_manager.py index 70bff8194ad..d214403291c 100644 --- a/cecli/tools/resource_manager.py +++ b/cecli/tools/resource_manager.py @@ -26,6 +26,7 @@ class Tool(BaseTool): "description": ( "Manage files, long running commands, skills, and MCP servers" " in the chat context: add, read_only, create, remove files;" + " view up to 3 pages of command output with paging;" " stop background commands; load/remove skills and load/remove MCP servers." ), "parameters": { @@ -35,14 +36,16 @@ class Tool(BaseTool): "type": "array", "items": {"type": "string"}, "description": ( - "List of file paths to add to context. Limit to at most 2 at a time." + "List of file paths to add to context. Limit to at most 2 at a time. " + "Command output aliases (command_key::) are rejected; use paging instead." ), }, "read_only": { "type": "array", "items": {"type": "string"}, "description": ( - "List of file paths to add as read-only. Limit to at most 2 at a time." + "List of file paths to add as read-only. Limit to at most 2 at a time. " + "Command output aliases (command_key::) are rejected; use paging instead." ), }, "create": { @@ -92,6 +95,29 @@ class Tool(BaseTool): 'Possible values: "list_mcp_servers" to list MCP servers.' ), }, + "paging": { + "type": "array", + "description": ( + "View 1-3 saved command output pages without adding files to context. " + 'Format: [{"target": "", "page": 1}]. ' + "Pages are numbered from 1." + ), + "items": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": ( + "Command key returned by Command (e.g., bg_1_1234), " + "matching ^bg_[0-9]+_[0-9]+$." + ), + }, + "page": {"type": "integer", "minimum": 1}, + }, + "required": ["target", "page"], + "additionalProperties": False, + }, + }, }, "additionalProperties": False, "required": [], @@ -113,6 +139,7 @@ async def execute( load_mcp=None, remove_mcp=None, actions=None, + paging=None, **kwargs, ): """Perform batch operations on the coder's context. @@ -124,9 +151,9 @@ async def execute( remove: list[str] | None Files to remove from the context. add: list[str] | None - Files to promote to editable status. - view: list[str] | None - Files to add as read-only view. + Files to promote to editable status (not command output aliases). + read_only: list[str] | None + Files to add as read-only (not command output aliases). create: list[str] | None Files to create and make editable. stop: list[str] | None @@ -141,6 +168,9 @@ async def execute( MCP server names to remove. actions: list[str] | None Action operations to perform (e.g., "list_mcp_servers"). + paging: list[dict] | None + One to three saved output pages: [{"target": command_key, "page": positive_int}]. + Returns contents directly without adding the pages to file context. """ remove_files = sorted(parse_arg_as_list(remove), key=cls._natural_sort_key) editable_files = sorted(parse_arg_as_list(add), key=cls._natural_sort_key) @@ -153,6 +183,16 @@ async def execute( remove_mcp_servers = sorted(parse_arg_as_list(remove_mcp), key=cls._natural_sort_key) action_operations = sorted(parse_arg_as_list(actions), key=cls._natural_sort_key) + for file_path in editable_files + view_files: + if file_path.startswith("command_key::"): + raise ToolError( + "Command output files cannot be viewed with add or read_only. " + 'Use paging=[{"target": "", "page": 1}] instead.' + ) + + if paging is not None: + cls._validate_paging(paging) + if ( not remove_files and not editable_files @@ -164,15 +204,25 @@ async def execute( and not load_mcp_servers and not remove_mcp_servers and not action_operations + and paging is None ): raise ToolError( - "You must specify at least one of: remove, editable, view, create, stop, " - "load_skill, remove_skill, load_mcp, remove_mcp, or actions" + "You must specify at least one of: remove, add, read_only, create, stop, " + "load_skill, remove_skill, load_mcp, remove_mcp, actions, or paging" ) coder.io.tool_output("⛭ Modifying Context", type="tool-result") response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE) + for page_request in paging or []: + try: + response.append_result(cls._read_command_page(coder, page_request)) + except OSError as e: + response.append_error( + f"Unable to read command output page {page_request['page']} " + f"for {page_request['target']}: {e}" + ) + # Expand wildcards for MCP operations if "*" in load_mcp_servers and coder.mcp_manager: servers = coder.mcp_manager.servers or [] @@ -289,6 +339,9 @@ def format_output(cls, coder, mcp_server, tool_response): file_list = ", ".join(files) coder.io.tool_output(f"{color_start}{display_name}:{color_end} {file_list}") + if params.get("paging") is not None: + coder.io.tool_output(f"{color_start}paging:{color_end} {params['paging']}") + tool_footer(coder=coder, tool_response=tool_response, params=params) @classmethod @@ -626,3 +679,40 @@ def _is_context_block_active(cls, coder, block_name): def _natural_sort_key(cls, s: str) -> list: """Natural sort key that splits "a10b2" into ["a", 10, "b", 2].""" return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)] + + @staticmethod + def _validate_paging(paging): + """Require one to three command keys with positive, one-based page numbers. + + Restrict targets to generated command keys so paging cannot read arbitrary + files or traverse outside the command's saved output folder. Validate the + entire request before reading any pages or performing context operations. + """ + if not isinstance(paging, list) or not 1 <= len(paging) <= 3: + raise ToolError( + 'paging must be an array of 1-3 objects: [{"target": "", "page": 1}].' + ) + + for page_request in paging: + if not isinstance(page_request, dict) or set(page_request) != {"target", "page"}: + raise ToolError('Each paging entry must be {"target": "", "page": 1}.') + + target = page_request["target"] + page = page_request["page"] + if not isinstance(target, str) or not re.fullmatch(r"bg_[0-9]+_[0-9]+", target): + raise ToolError("paging.target must be a command key (e.g., bg_1_1234).") + + if type(page) is not int or page < 1: + raise ToolError("paging.page must be a positive integer starting at 1.") + + @classmethod + def _read_command_page(cls, coder, paging): + """Return one saved output page without registering it as a context file.""" + target = paging["target"] + page = paging["page"] + rel_path = coder.local_agent_folder(f"{target}/{page}.txt") + abs_path = coder.abs_root_path(rel_path) + with safe_open(abs_path, "r") as page_file: + output = page_file.read() + + return f"Command output: {target}, page {page}\n{output}" diff --git a/cecli/tui/__init__.py b/cecli/tui/__init__.py index 23daaaac634..35fd7a0129c 100644 --- a/cecli/tui/__init__.py +++ b/cecli/tui/__init__.py @@ -74,8 +74,10 @@ async def launch_tui(coder, output_queue, input_queue, args): Returns: Exit code from TUI """ - # Pin tqdm's class lock before the TUI captures stdout/stderr (see _pin_tqdm_lock). + # Prepare tqdm and multiprocessing before the TUI captures stdout/stderr + # (fileno() == -1), so later subprocesses can still spawn (see helpers below). _pin_tqdm_lock() + _pre_start_resource_tracker() worker = None return_code = 0 @@ -122,3 +124,21 @@ def _pin_tqdm_lock(): tqdm.std.tqdm.set_lock(threading.RLock()) except Exception: pass + + +def _pre_start_resource_tracker(): + """Start multiprocessing's resource tracker before the TUI captures stdout/stderr. + + The Textual TUI replaces ``sys.stdout``/``sys.stderr`` with capture streams whose + ``fileno()`` returns ``-1``. ``multiprocessing`` launches a resource tracker + subprocess through those fds, so starting it after the swap makes ``spawnv_passfds`` + raise ``"ValueError: bad value(s) in fds_to_keep"``. Starting it while the real fds + are still present lets forked workers (e.g. the ``ProcessPoolExecutor`` behind /voice) + register locks and semaphores without re-launching the tracker. + """ + try: + import multiprocessing.resource_tracker as resource_tracker + + resource_tracker.ensure_running() + except Exception: + pass diff --git a/cecli/tui/app.py b/cecli/tui/app.py index f33a40930f5..7e5dfd32af3 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -79,6 +79,9 @@ def __init__(self, coder_worker, output_queue, input_queue, args): self._sub_agent_containers = {} # uuid -> OutputContainer self._primary_coder_uuid = self.worker.coder.uuid + self._voice_stop_queue = None + self._voice_stopping = False + # Confirmation lock and pending queue — ensures one confirmation at a time self._confirmation_lock = False self._confirmation_coder_uuid = None @@ -192,6 +195,13 @@ def __init__(self, coder_worker, output_queue, input_queue, args): self._encode_keys(self.get_keys_for("quit")), "quit", description="Quit", show=True ) + self.bind( + self._encode_keys(self.get_keys_for("voice")), + "start_voice", + description="Record Voice", + show=True, + ) + self.register_theme(BASE_THEME) self.theme = "cecli" @@ -272,7 +282,8 @@ def _get_config(self): "prev_agent": "alt+ctrl+left", "main_agent": "alt+ctrl+up", "editor": "ctrl+o", - "history": "ctrl+r", + "history": "alt+shift+h", + "voice": "ctrl+r", "focus": "ctrl+f", "cancel": "ctrl+c", "clear": "ctrl+l", @@ -469,6 +480,14 @@ def update_cost(self, cost_text: str): except Exception: pass + def set_voice_hint(self, text: str): + """Set the key-hint right panel while voice recording is active.""" + try: + hints = self.query_one(KeyHints) + hints.update_right(text) + except Exception: + pass + def _update_key_hints_for_commands(self, text: str, is_completion: bool = False): """ Update key hints left area with command description. @@ -802,8 +821,15 @@ def on_input_area_submit(self, message: InputArea.Submit): if not user_input.strip(): return - # Intercept /editor and /edit commands to handle with TUI suspension stripped = user_input.strip() + + if stripped == "/voice": + input_area = self.query_one("#input", InputArea) + input_area.value = "" + self.action_start_voice() + return + + # Intercept /editor and /edit commands to handle with TUI suspension if ( stripped in ("/editor", "/edit") or stripped.startswith("/editor ") @@ -894,15 +920,17 @@ def on_input_area_submit(self, message: InputArea.Submit): self._handle_workspace_command(user_input, stripped) return - # Intercept queue management commands (/queue, /list-queue, /remove-queue) - # to dispatch immediately without a full generation cycle - they only - # modify the active coder's prompt_queue. + # Intercept queue management commands (/queue, /list-queue, /remove-queue, + # /insert-queue) to dispatch immediately without a full generation cycle - + # they only modify the active coder's prompt_queue. if ( stripped == "/queue" or stripped.startswith("/queue ") or stripped == "/list-queue" or stripped == "/remove-queue" or stripped.startswith("/remove-queue ") + or stripped == "/insert-queue" + or stripped.startswith("/insert-queue ") ): self._handle_queue_command(stripped) return @@ -1034,6 +1062,29 @@ def _handle_queue_command(self, stripped: str) -> None: self._get_visible_container().add_output("\n".join(lines)) + elif cmd == "/insert-queue": + parts = args.split(maxsplit=1) + if len(parts) != 2: + self.show_error("Usage: /insert-queue ") + return + + try: + index = int(parts[0]) + except ValueError: + self.show_error(f"Invalid index: '{parts[0]}'. Please provide a number.") + return + + prompt_text = parts[1].strip() + try: + item = command_queue.insert_prompt(active_coder, prompt_text, index) + except (ValueError, RuntimeError) as e: + self.show_error(str(e)) + return + + self._get_visible_container().add_output( + f"Prompt inserted at position {index + 1} (id: {item['id']})" + ) + elif cmd == "/remove-queue": if not args: self.show_error("Usage: /remove-queue ") @@ -1215,6 +1266,20 @@ def action_history_search(self): input_area = self.query_one("#input", InputArea) input_area.post_message(input_area.Submit("/history-search")) + def action_start_voice(self): + """Toggle background recording without entering the agent's input queue.""" + if self._voice_stop_queue is not None: + if not self._voice_stopping: + self._voice_stop_queue.put(None) + self._voice_stopping = True + + return + + coder = self._get_visible_coder() + self._voice_stop_queue = queue.Queue() + self._voice_stopping = False + self.run_worker(self._run_voice(coder, self._voice_stop_queue), group="voice") + def action_open_editor(self): """Open an external editor to compose a prompt (keyboard shortcut).""" # Get current input text to use as initial content @@ -2045,6 +2110,20 @@ def on_completion_bar_dismissed(self, message: CompletionBar.Dismissed): input_area.completion_active = False input_area.focus() + async def _run_voice(self, coder, stop_queue): + """Run voice callbacks on the UI thread and release the toggle on completion.""" + from cecli.commands.voice import VoiceCommand + + try: + self.set_voice_hint("⬤ recording") + await VoiceCommand.execute(coder.io, coder, "", stop_queue=stop_queue) + except Exception as err: + self.show_error(f"Unable to record voice: {err}") + finally: + self._voice_stop_queue = None + self._voice_stopping = False + self.update_key_hints(generating=self._currently_generating) + def patch_color_name_to_rgb(): """Inject Rich 256-color names into Textual's COLOR_NAME_TO_RGB dict. diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index 198dd70dd84..f3c2867783e 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -239,6 +239,25 @@ def on_key(self, event) -> None: if self.disabled: return + # Route hotkey bindings directly and stop TextArea from inserting a + # printable character (e.g. alt+shift+h would otherwise type an "h"). + if self.app.is_key_for("voice", event.key): + event.stop() + event.prevent_default() + self.app.action_start_voice() + return + + if self.app.is_key_for("history", event.key): + event.stop() + event.prevent_default() + self.app.action_history_search() + return + + # Reserve any other alt+shift combination for hotkeys (no character input). + if event.key and "alt+shift" in event.key: + event.stop() + event.prevent_default() + return # Reset cycling if not a cycle command is_cycle = self.app.is_key_for("cycle_forward", event.key) or self.app.is_key_for( "cycle_backward", event.key diff --git a/cecli/tui/widgets/input_container.py b/cecli/tui/widgets/input_container.py index 442c404379f..ac2f002451d 100644 --- a/cecli/tui/widgets/input_container.py +++ b/cecli/tui/widgets/input_container.py @@ -36,7 +36,6 @@ def update_mode(self, mode: str): mode: The coder edit format (e.g. "code", "agent"). """ self.coder_mode = mode - sub_agents = self._get_sub_agents() if sub_agents: pills_text = self._format_sub_agent_pills(sub_agents, self.show_squares) diff --git a/cecli/voice.py b/cecli/voice.py index 8b8a5933aab..e9dd833f948 100644 --- a/cecli/voice.py +++ b/cecli/voice.py @@ -1,24 +1,152 @@ +"""On-device voice recording and transcription. + +Records microphone audio (via ``sounddevice``) and transcribes it locally using +Moonshine's on-device models instead of sending the audio to OpenAI's Whisper +API. The model is downloaded and cached on first use. When an ``on_text`` +callback is supplied the transcriber streams partial transcripts back as the +model generates them, so callers can feed the user's input in chunks. +""" + import asyncio import os import sys import tempfile from concurrent.futures import ProcessPoolExecutor -from cecli.decoding import safe_open +# Below this RMS level a recording is treated as silent (no usable mic input). +_SILENCE_RMS_THRESHOLD = 0.005 + +# Moonshine voice-asset language tags (ISO639-1) whose models are shipped. +_MOONSHINE_LANGUAGES = ("ar", "es", "de", "en", "ja", "ko", "vi", "uk", "zh", "tl") + +# Human-readable language names -> Moonshine tags. These cover the values that +# ``coder.get_user_language()`` returns (via ``normalize_language``). +_LANGUAGE_NAME_TO_CODE = { + "english": "en", + "spanish": "es", + "german": "de", + "arabic": "ar", + "japanese": "ja", + "korean": "ko", + "vietnamese": "vi", + "ukrainian": "uk", + "chinese": "zh", + "mandarin": "zh", + "tagalog": "tl", +} + +# Languages whose tokenizer does not use the Latin alphabet require a higher +# hallucination-detection threshold in Moonshine's transcriber. +_NON_LATIN_LANGUAGES = frozenset(("ar", "ja", "zh", "ko", "uk")) + +# Languages that ship a streaming model. Korean and Ukrainian do not yet, so +# those fall back to the buffered (non-streaming) path even with ``on_text``. +_STREAMING_LANGUAGES = frozenset(("ar", "de", "es", "en", "ja", "tl", "vi", "zh")) + +# Sentinel placed on the cross-process text queue once streaming is finished. +_STREAM_END = "\0__MOONSHINE_STREAM_END__" + +# Hugging Face mirror for Moonshine's native ONNX runtime models. +_HF_REPO_ID = "moonshine-ai/moonshine-voice-assets" +_HF_MODELS = { + "en": { + "path": "model/small-streaming-en/quantized_26_08_21", + "files": ( + "adapter.ort", + "cross_kv.ort", + "decoder_kv.ort", + "encoder.ort", + "frontend.model.ort", + "frontend.weights.ort", + "streaming_config.json", + "tokenizer.bin", + ), + }, +} + + +class SoundDeviceError(Exception): + """Raised when the audio recording stack cannot be initialized.""" + + +def resolve_moonshine_language(voice_language=None, user_language=None): + """Resolve the Moonshine model language from a voice-language setting. + + Precedence is an explicit ``voice_language`` value, then the detected + user/chat language, finally ``en``. Each input may be an ISO639-1 code or a + human-readable language name (e.g. ``English``). + """ + code = _normalize_moonshine_language(voice_language) + + if code: + return code + + code = _normalize_moonshine_language(user_language) + + if code: + return code + + return "en" class Voice: + """Record microphone audio and transcribe it on-device with Moonshine.""" + def __init__(self, audio_format="wav", device_name=None): + try: + import sounddevice # noqa: F401 + import soundfile # noqa: F401 + except (ImportError, OSError) as exc: + raise SoundDeviceError(str(exc)) from exc + self.audio_format = audio_format self.device_name = device_name self._executor = ProcessPoolExecutor(max_workers=1) - async def record_and_transcribe(self, history=None, language=None): + async def record_and_transcribe( + self, + history=None, + language=None, + on_text=None, + on_status=None, + stop_binding=None, + stop_queue=None, + ): + """Record until Enter, or until any item arrives on ``stop_queue``. + + ``stop_queue`` is a caller-owned, thread-safe ``queue.Queue``. Queue + mode never accesses stdin. Cancellation signals the worker in queue + mode and waits for it to finish before releasing IPC resources. In + CLI mode the worker still needs Enter before cancellation can finish. + """ + import multiprocessing + loop = asyncio.get_running_loop() - stdin_fd = sys.stdin.fileno() + stdin_fd = sys.stdin.fileno() if stop_queue is None else None + manager = None + text_queue = None + status_queue = None + worker_stop_queue = None + stop_task = None + drain_tasks = [] try: - return await loop.run_in_executor( + if on_text is not None or on_status is not None or stop_queue is not None: + manager = multiprocessing.Manager() + + if on_text is not None: + text_queue = manager.Queue() + drain_tasks.append(loop.create_task(_drain_text_queue(text_queue, on_text))) + + if on_status is not None: + status_queue = manager.Queue() + drain_tasks.append(loop.create_task(_drain_status_queue(status_queue, on_status))) + + if stop_queue is not None: + worker_stop_queue = manager.Queue() + stop_task = loop.create_task(_bridge_stop_queue(stop_queue, worker_stop_queue)) + + worker_future = loop.run_in_executor( self._executor, _run_record_process, stdin_fd, @@ -26,36 +154,93 @@ async def record_and_transcribe(self, history=None, language=None): self.device_name, history, language, + text_queue, + status_queue, + stop_binding, + worker_stop_queue, ) - except Exception as e: - print(f"Error in transcription: {e}") - return None + try: + return await asyncio.shield(worker_future) + except asyncio.CancelledError: + if worker_stop_queue is not None: + worker_stop_queue.put(None) + + while not worker_future.done(): + try: + await asyncio.shield(worker_future) + except asyncio.CancelledError: + continue + except Exception: + break + + if not worker_future.cancelled(): + worker_future.exception() + + raise + + finally: + if stop_task is not None: + stop_task.cancel() + await asyncio.gather(stop_task, return_exceptions=True) -def _run_record_process(stdin_fd, audio_format, device_name, history, language): + for drain_task in drain_tasks: + try: + await asyncio.wait_for(drain_task, timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError): + drain_task.cancel() + + if manager is not None: + manager.shutdown() + + def close(self): + """Shut down the owned executor after recording has finished. + + This synchronous method waits for worker processes to exit. Stop and + await any active recording before calling it; a Voice cannot be used + again after it is closed. + """ + self._executor.shutdown(wait=True) + + +def _run_record_process( + stdin_fd, + audio_format, + device_name, + history, + language, + text_queue=None, + status_queue=None, + stop_binding=None, + stop_queue=None, +): + """Record mic audio and transcribe it on-device with Moonshine. + + Runs in a worker process so blocking mic/recording and model inference do + not stall the asyncio event loop. When ``text_queue`` is provided and the + language has a streaming model the audio is streamed into a Moonshine + ``Transcriber`` and intermediate transcripts are pushed onto the queue as + they are produced. The ``history`` context the + cloud API accepted is not applied; Moonshine transcription runs on-device. + """ import queue import sounddevice as sd import soundfile as sf - from cecli.llm import litellm - - # Re-link terminal input - sys.stdin = os.fdopen(os.dup(stdin_fd)) + if stop_queue is None: + # Only the CLI worker owns terminal input. + sys.stdin = os.fdopen(os.dup(stdin_fd)) q = queue.Queue() def callback(indata, frames, time, status): q.put(indata.copy()) - # 1. Securely create the temporary file - # delete=False is required so we can close the handle and let 'soundfile' open it again - with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp_file: - temp_path = tmp_file.name - try: - # Device Setup + # Device setup. device_id = None + if device_name: for i, d in enumerate(sd.query_devices()): if device_name in d["name"]: @@ -65,28 +250,409 @@ def callback(indata, frames, time, status): info = sd.query_devices(device_id, "input") sample_rate = int(info["default_samplerate"]) - # Recording - with sd.InputStream( - samplerate=sample_rate, channels=1, callback=callback, device=device_id - ): - print("\nRecording... Press ENTER to stop.") - sys.stdin.readline() - - # 2. Write buffered audio using the named path - with sf.SoundFile(temp_path, mode="w", samplerate=sample_rate, channels=1) as file: - while not q.empty(): - file.write(q.get()) - - # 3. Transcription - with safe_open(temp_path, "rb") as fh: - print("\nTranscribing...") - transcript = litellm.transcription( - model="whisper-1", file=fh, prompt=history, language=language + if text_queue is not None and language in _STREAMING_LANGUAGES: + return _record_and_stream( + q, + callback, + sample_rate, + device_id, + language, + text_queue, + status_queue, + stop_binding, + stop_queue, ) - return transcript.text + # Buffered path: record into a temp WAV, then transcribe the whole clip. + with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp_file: + temp_path = tmp_file.name + + try: + with sd.InputStream( + samplerate=sample_rate, channels=1, callback=callback, device=device_id + ): + _status(status_queue, f"\n⬤ recording: {stop_binding or 'Enter'} to stop") + _wait_for_stop(stop_queue) + + # Write buffered audio using the named path. + total_energy = 0.0 + total_samples = 0 + + with sf.SoundFile( + temp_path, mode="w", samplerate=sample_rate, channels=1, format="WAV" + ) as file: + while not q.empty(): + block = q.get() + file.write(block) + total_energy += float((block * block).sum()) + total_samples += block.size + if total_samples and (total_energy / total_samples) ** 0.5 < _SILENCE_RMS_THRESHOLD: + _status( + status_queue, + "\nNo audio detected on the input device - check that your microphone is " + "selected, unmuted, and reachable from this session.", + ) + + # On-device transcription. + _status(status_queue, "\n⬤ Transcribing") + return _transcribe_local(temp_path, language, status_queue) + finally: + # Manual cleanup since delete=False was used. + if os.path.exists(temp_path): + os.remove(temp_path) finally: - # 4. Manual cleanup since delete=False was used - if os.path.exists(temp_path): - os.remove(temp_path) + if text_queue is not None: + text_queue.put(_STREAM_END) + + if status_queue is not None: + status_queue.put(_STREAM_END) + + +def _record_and_stream( + q, + callback, + sample_rate, + device_id, + language, + text_queue, + status_queue=None, + stop_binding=None, + stop_queue=None, +): + """Stream microphone audio and return the assembled transcript. + + A feeder thread keeps the sounddevice callback non-blocking. Partial text + is pushed cumulatively; the feeder is joined before the transcriber is + closed, including on recording errors. Any stop queue item ends recording; + without a queue, Enter ends recording as in the CLI buffered path. + """ + import threading + + import sounddevice as sd + from moonshine_voice.transcriber import LineCompleted, LineStarted, LineTextChanged + + transcriber = _build_transcriber(language, status_queue) + completed_lines = [] + current_line = "" + last_pushed = "" + total_energy = 0.0 + total_samples = 0 + + def _push_cumulative(): + nonlocal last_pushed + + parts = [part.strip() for part in completed_lines if part and part.strip()] + current = current_line.strip() + + if current: + parts.append(current) + + if parts: + joined = " ".join(parts) + + if joined != last_pushed: + text_queue.put(joined) + last_pushed = joined + + def on_event(event): + nonlocal current_line + + if event is None or event.line is None: + return + + if isinstance(event, LineStarted): + current_line = "" + _push_cumulative() + elif isinstance(event, LineTextChanged): + current_line = event.line.text or "" + _push_cumulative() + elif isinstance(event, LineCompleted): + text = (event.line.text or "").strip() + + if text: + completed_lines.append(text) + + current_line = "" + _push_cumulative() + + def feed(): + nonlocal total_energy, total_samples + + while True: + block = q.get() + + if block is None: + break + + data = block.reshape(-1).tolist() + transcriber.add_audio(data, sample_rate) + total_energy += float((block * block).sum()) + total_samples += block.size + + try: + transcriber.add_listener(on_event) + transcriber.start() + feed_thread = threading.Thread(target=feed, daemon=True) + feed_thread.start() + + try: + with sd.InputStream( + samplerate=sample_rate, channels=1, callback=callback, device=device_id + ): + _status(status_queue, f"\n⬤ recording: {stop_binding or 'Enter'} to stop") + _wait_for_stop(stop_queue) + finally: + q.put(None) + feed_thread.join() + + if total_samples and (total_energy / total_samples) ** 0.5 < _SILENCE_RMS_THRESHOLD: + _status( + status_queue, + "\nNo audio detected on the input device - check that your microphone is " + "selected, unmuted, and reachable from this session.", + ) + + transcript = transcriber.stop() + return _join_transcript(transcript) + finally: + transcriber.close() + + +def _transcribe_local(wav_path, language="en", status_queue=None): + """Transcribe a mono WAV on-device using Moonshine. + + Downloads and caches the model via ``moonshine_voice`` on first use. The + ``language`` is a Moonshine tag (``en``, ``es``, ``zh``, ...). + """ + from moonshine_voice.utils import load_wav_file + + audio_data, sample_rate = load_wav_file(wav_path) + transcriber = _build_transcriber(language, status_queue) + + try: + transcript = transcriber.transcribe_without_streaming(audio_data, sample_rate=sample_rate) + finally: + transcriber.close() + + return _join_transcript(transcript) + + +def _build_transcriber(language, status_queue=None): + from moonshine_voice import Transcriber + + language = language or "en" + model_arch = _model_arch_for_language(language) + model_root = _model_root_for_language(language, model_arch, status_queue) + + options = None + + if language in _NON_LATIN_LANGUAGES: + options = {"max_tokens_per_second": 13.0} + + return Transcriber( + model_path=model_root, + model_arch=model_arch, + options=options, + ) + + +def _model_root_for_language(language, model_arch, status_queue=None): + """Return the local model root, preferring the Hugging Face mirror. + + ``en`` is fetched from Hugging Face so corporate networks that block + Moonshine's CDN can still download it, falling back to Moonshine's CDN-backed + loader when the mirror is unreachable. Other languages use the CDN loader. + """ + from moonshine_voice import get_model_for_language + + if language in _HF_MODELS: + try: + return _download_model_from_hf(language, status_queue) + except Exception: + # The Hugging Face mirror is unreachable; fall back to the CDN. + pass + + root, _ = get_model_for_language( + language, + model_arch, + on_progress=_download_progress(status_queue), + ) + + return root + + +def _model_arch_for_language(language): + from moonshine_voice import ModelArch + + if language == "en": + return ModelArch.SMALL_STREAMING + + return None + + +def _normalize_moonshine_language(value): + if not value: + return None + + normalized = value.strip().lower() + + if normalized in _MOONSHINE_LANGUAGES: + return normalized + + # Strip a locale/script variant, e.g. ``zh-CN`` -> ``zh`` or ``en_US`` -> ``en``. + primary = normalized.replace("-", "_").split("_")[0] + + if primary in _MOONSHINE_LANGUAGES: + return primary + + return _LANGUAGE_NAME_TO_CODE.get(normalized) + + +def _join_transcript(transcript): + lines = [line.text.strip() for line in transcript.lines if line.text and line.text.strip()] + + return " ".join(lines) if lines else None + + +async def _drain_text_queue(text_queue, on_text): + """Forward partial transcripts from the worker to ``on_text``. + + Runs as an asyncio task so the blocking ``text_queue.get`` calls are + marshalled onto a worker thread without stalling the event loop. + """ + loop = asyncio.get_running_loop() + + while True: + item = await loop.run_in_executor(None, text_queue.get) + + if item == _STREAM_END: + break + + try: + on_text(item) + except Exception: + pass + + +async def _drain_status_queue(status_queue, on_status): + """Forward status messages from the worker to ``on_status``.""" + loop = asyncio.get_running_loop() + + while True: + item = await loop.run_in_executor(None, status_queue.get) + + if item == _STREAM_END: + break + + try: + on_status(item) + except Exception: + pass + + +def _status(status_queue, message): + """Report a status message to the caller, or ``print`` it when no queue is used.""" + if status_queue is not None: + status_queue.put(message) + else: + print(message) + + +def _download_model_from_hf(language, status_queue=None): + """Download the native Moonshine model for ``language`` from Hugging Face. + + Fetches the exact file set the native ``Transcriber`` expects (mirrored in + Moonshine's Hugging Face repo) into the Moonshine cache, so a network + that blocks ``download.moonshine.ai`` can still load ``en``. Returns + the local model root directory. + """ + from pathlib import Path + + import requests + from moonshine_voice.download_file import get_cache_dir + + info = _HF_MODELS[language] + cache_dir = Path(get_cache_dir()) + + # Reuse a model already cached via Moonshine's CDN loader so users on + # networks where both endpoints are reachable do not re-download it from + # Hugging Face. The CDN cache mirrors the same ``path`` layout. + cdn_root = cache_dir / "download.moonshine.ai" / info["path"] + + if all((cdn_root / name).exists() for name in info["files"]): + return str(cdn_root) + + model_root = cache_dir / "huggingface" / info["path"] + model_root.mkdir(parents=True, exist_ok=True) + + names = [name for name in info["files"] if not (model_root / name).exists()] + + if names: + _status(status_queue, "Downloading the Moonshine model from Hugging Face...") + + for name in info["files"]: + dest = model_root / name + + if dest.exists(): + continue + + url = f"https://huggingface.co/{_HF_REPO_ID}/resolve/main/{info['path']}/{name}" + + with requests.get(url, stream=True, timeout=(10, 300)) as response: + response.raise_for_status() + tmp = dest.with_suffix(dest.suffix + ".partial") + + with open(tmp, "wb") as file: + for chunk in response.iter_content(chunk_size=1 << 20): + if chunk: + file.write(chunk) + + os.replace(tmp, dest) + + return str(model_root) + + +def _download_progress(status_queue=None): + """Return a progress callback that silences Moonshine's tqdm download bar. + + It also reports a one-time status notice on the first download tick so + users know the model is being fetched. When the model is already cached + the callback is never invoked, so no misleading notice is shown. + """ + reported = False + + def on_progress(fraction, file): + nonlocal reported + + if not reported: + reported = True + _status(status_queue, "Downloading the Moonshine model...") + + return on_progress + + +def _wait_for_stop(stop_queue): + """Wait for any queue item, or Enter in the legacy CLI mode.""" + if stop_queue is not None: + stop_queue.get() + else: + sys.stdin.readline() + + +async def _bridge_stop_queue(stop_queue, worker_stop_queue): + """Poll caller-owned input without tying up an executor thread. + + Only arrival matters, so forward a fixed token rather than requiring the + caller's payload to be picklable. + """ + import queue + + while True: + try: + stop_queue.get_nowait() + except queue.Empty: + await asyncio.sleep(0.05) + else: + worker_stop_queue.put(None) + return diff --git a/cecli/website/docs/config/skills.md b/cecli/website/docs/config/skills.md index 813c49f5bd0..3448eeb9005 100644 --- a/cecli/website/docs/config/skills.md +++ b/cecli/website/docs/config/skills.md @@ -104,6 +104,33 @@ agent-config: | } ``` +## Importing Skills + +You can import a skills from the cecli community registry or from [skills.sh](https://www.skills.sh). Importing is only available in Agent Mode. + +In the chat, use the `/import-skill` command: + +``` +/import-skill # Import into the project's .cecli/skills +/import-skill --global # Import into ~/.cecli/skills +``` + +The skill name may be a path within the registry, for example `web/browser-harness`, `files/docx`, or `pdf`. cecli looks the skill up in the community registry (`cecli-dev/community-resources`) first and falls back to skills.sh if it is not found there. + +- `/import-skill ` downloads the skill into the project's `.cecli/skills` directory. +- `/import-skill --global ` (or `-g`) downloads the skill into `~/.cecli/skills`, making it available across all projects. + +For example: + +``` +/import-skill files/docx # Import the docx skill from the community registry +/import-skill --global pdf # Install the PDF skill globally +``` + +After importing, the skill is added to the current session just like `/include-skill`. If your configuration has a `skills_includelist`, the skill is also added to it so it survives restarts; otherwise the skill is auto-discovered in future sessions. + +See the [community-resources repository](https://github.com/cecli-dev/community-resources) for the list of available skills. + ## Creating Custom Skills To create a custom skill: diff --git a/cecli/website/docs/usage/commands.md b/cecli/website/docs/usage/commands.md index 9650275ebac..e3f47303fab 100644 --- a/cecli/website/docs/usage/commands.md +++ b/cecli/website/docs/usage/commands.md @@ -57,6 +57,56 @@ Cecli supports commands from within the chat, which all start with `/`. | **/weak-model** | Switch the Weak Model to a new LLM | | **/web** | Scrape a webpage, convert to markdown and send in a message | + + +## Prompt Queue Management + +| Command | Description | +| :--- | :--- | +| **/queue** | Queue a prompt for processing after current tasks complete | +| **/list-queue** | List all prompts currently in the queue | +| **/remove-queue** | Remove a prompt from the queue by index, or '*' to clear all | + +{: .tip } + +## Prompt Queue Management Commands + +The prompt queue management feature (`CLI-33`) adds three new commands for managing a first-in-first-out (FIFO) queue of prompts. + +### Queue Commands + +| Command | Description | +|---------|-------------| +| **/queue** | Queue a prompt for processing after current tasks complete | +| **/insert-queue** | Insert a prompt at a specific position in the queue | +| **/list-queue** | List all prompts currently in the queue | +| **/remove-queue** | Remove a prompt from the queue by index, or '*' to clear all | + +#### `/queue` Command + +**Usage:** `/queue ` + +**Description:** Adds a prompt to the queue for processing after the current command completes. + +**Examples:** +```bash +/queue "refactor database layer" +/queue "add unit tests for user service" +``` + +#### `/insert-queue` Command + +**Usage:** `/insert-queue `, `/insert-queue ` + +**Description:** Inserts a prompt at a specific position in the queue. When called without an index, the prompt is added at the front of the queue. + +**Examples:** +```bash +/insert-queue "add tests for login" +/insert-queue 3 "refactor database layer" +``` + + > **Tip:** You can easily re-send commands or messages. Use the up arrow ⬆ to scroll back or CONTROL-R to search your message history. diff --git a/cecli/website/docs/usage/modes.md b/cecli/website/docs/usage/modes.md index d51aeafe109..960421375f2 100644 --- a/cecli/website/docs/usage/modes.md +++ b/cecli/website/docs/usage/modes.md @@ -61,7 +61,7 @@ You can activate agent mode in several ways: - **Autonomous file management**: cecli discovers and manages relevant files itself, rather than relying only on the files you explicitly add. - **Enhanced context management**: entering agent mode enables context management for large files, and you can tune it with `/context-management` and `/context-blocks`. -- **Skills**: loading, including, excluding, and removing skills is only available in agent mode (`/load-skill`, `/include-skill`, `/exclude-skill`, `/remove-skill`). +- **Skills**: loading, including, excluding, and removing skills is only available in agent mode (`/load-skill`, `/include-skill`, `/exclude-skill`, `/remove-skill`, `/import-skill`). - **Sub-agents**: delegate sub-tasks to specialized sub-agents for parallel or focused work. - **Dedicated agent model**: choose a separate model for agent mode with `--agent-model` or `/agent-model`. diff --git a/cecli/website/docs/usage/voice.md b/cecli/website/docs/usage/voice.md index d46bad74f87..444f3b23462 100644 --- a/cecli/website/docs/usage/voice.md +++ b/cecli/website/docs/usage/voice.md @@ -1,58 +1,61 @@ --- parent: Usage nav_order: 100 -description: Speak with cecli about your code! +description: Use your voice, not your hands --- # Voice Mode -Speak with cecli about your code! Request new features, test cases or bug fixes using your voice and let cecli do the work of editing the files in your local git repo. As with all of cecli's capabilities, you can use voice-to-code with an existing repo or to start a new project. - -Voice support fits quite naturally into cecli's AI assistance. You can fluidly switch between voice and text chat when you ask cecli to edit your code. +`/voice` records your microphone and transcribes your speech locally (on-device with [Moonshine AI's lightweight STT models](https://github.com/moonshine-ai/moonshine)), placing the transcript in the input field where you can edit it before submitting. ## How to use voice-to-code -Use the in-chat `/voice` command to start recording, and press `ENTER` when you're done speaking. Your voice coding instructions will be transcribed, as if you had typed them into the cecli chat session. +Type `/voice` and press Enter to start recording. Speak, then press Enter again (or your configured submit key) to stop and transcribe. + +In the TUI you can start a recording at any time by pressing `ctrl+r`. + +--- + +## Audio setup in WSL + +If you run cecli from WSL (Windows Subsystem for Linux), `/voice` needs a little extra setup to reach your Windows microphone. WSLg bridges audio through PulseAudio, but `sounddevice` (via PortAudio) talks to ALSA, so we route ALSA through Pulse to WSLg's `RDPSource`. On a native Linux or Windows install this isn't needed. -See the [installation instructions](../install.html) for information on how to enable the `/voice` command. +1. **Check the Windows mic.** Verify your microphone is set as the **default input device** (Settings → System → Sound → Input) and that apps may access it (Settings → Privacy & security → Microphone). -> cecli v0.11.2-dev -> Added app.py to the chat. +2. **Install PortAudio and the ALSA → Pulse plugin plus the PulseAudio utilities.** -#### /voice + - Fedora: -> Recording, press ENTER when done... 3.5sec + ```bash + sudo dnf install -y portaudio alsa-utils pulseaudio-utils alsa-plugins-pulseaudio + ``` -> "add a factorial endpoint that uses math factorial" + - Debian / Ubuntu: -#### Add a factorial endpoint that uses math.factorial. + ```bash + sudo apt install -y libportaudio2 alsa-utils pulseaudio-utils libasound2-plugins + ``` -To add a factorial endpoint to the Flask application, we need to modify the `app.py` file. -Here are the steps: +> **After installing PortAudio**, close and reopen your terminal (or restart WSL) so +> `sounddevice` picks up the newly installed PortAudio library — a running Python +> process keeps whichever PortAudio it loaded first. Also make sure Windows grants +> microphone access to WSLg (Settings → Privacy & security → Microphone → +> *allow desktop apps* / "Windows Subsystem for Linux"). -1. Add a new route `/fact/` to the Flask application. -2. In the function for this route, call `math.factorial(x)` and return the result as a string. +3. **Route ALSA through PulseAudio.** Create `~/.asoundrc`: -Here is the *edit block* for these changes: + ```bash + printf 'pcm.!default { type pulse }\nctl.!default { type pulse }\n' > ~/.asoundrc + ``` -```python -app.py -<<<<<<< HEAD -if __name__ == '__main__': - print("Starting...") - app.run() -======= -@app.route('/fact/') -def factorial(x): - result = math.factorial(x) - return str(result) +4. **Verify the mic is captured** (optional). Speak while this runs for a couple of seconds; an RMS well above `0` means WSLg is forwarding your mic: -if __name__ == '__main__': - print("Starting...") - app.run() ->>>>>>> updated -``` + ```bash + PULSE_SERVER=unix:/mnt/wslg/PulseServer \ + parec -d RDPSource --format=s16le --rate=44100 --channels=1 | \ + head -c 176400 | \ + python3 -c "import sys, numpy as np; a=np.frombuffer(sys.stdin.buffer.read(), np.int16)/32768.0; print('RMS', round(float(np.sqrt(np.mean(a**2))),4))" + ``` -> Applied edit to app.py -> Commit ef9e3e7 cecli: Add a factorial endpoint that uses math.factorial. +Once this is in place, `/voice` records from your Windows microphone and transcribes it on-device with [Moonshine AI's models](https://github.com/moonshine-ai/moonshine). diff --git a/requirements.txt b/requirements.txt index 56d75692d3a..64d185820fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -98,6 +98,7 @@ filelock==3.20.0 # via # -c requirements/common-constraints.txt # huggingface-hub + # moonshine-voice flatbuffers==25.12.19 # via # -c requirements/common-constraints.txt @@ -119,6 +120,10 @@ gitpython==3.1.45 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +google-crc32c==1.8.0 + # via + # -c requirements/common-constraints.txt + # moonshine-voice googleapis-common-protos==1.75.1 # via # -c requirements/common-constraints.txt @@ -222,6 +227,10 @@ mmh3==5.2.1 # via # -c requirements/common-constraints.txt # chromadb +moonshine-voice==0.1.5 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements.in mslex==1.3.0 # via # -c requirements/common-constraints.txt @@ -239,6 +248,7 @@ numpy==2.3.5 # via # -c requirements/common-constraints.txt # chromadb + # moonshine-voice # onnxruntime # rustworkx # soundfile @@ -314,6 +324,7 @@ pillow==12.0.0 platformdirs==4.5.0 # via # -c requirements/common-constraints.txt + # moonshine-voice # textual prompt-toolkit==3.0.52 # via @@ -431,6 +442,7 @@ requests==2.32.5 # -r requirements/requirements.in # huggingface-hub # kubernetes + # moonshine-voice # requests-oauthlib requests-oauthlib==2.0.0 # via @@ -481,6 +493,7 @@ sounddevice==0.5.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # moonshine-voice soundfile==0.13.1 # via # -c requirements/common-constraints.txt @@ -519,6 +532,7 @@ tqdm==4.67.1 # -r requirements/requirements.in # chromadb # huggingface-hub + # moonshine-voice # via # -c requirements/common-constraints.txt # -r requirements/requirements.in diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index f1e10a0bf65..5f79887e8e4 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -81,6 +81,7 @@ durationpy==0.10 filelock==3.20.0 # via # huggingface-hub + # moonshine-voice # virtualenv flake8==7.3.0 # via -r requirements/requirements-dev.in @@ -98,6 +99,8 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.45 # via -r requirements/requirements.in +google-crc32c==1.8.0 + # via moonshine-voice googleapis-common-protos==1.75.1 # via opentelemetry-exporter-otlp-proto-grpc greenlet==3.2.4 @@ -186,6 +189,8 @@ memray==1.19.2 # via -r requirements/requirements-dev.in mmh3==5.2.1 # via chromadb +moonshine-voice==0.1.5 + # via -r requirements/requirements.in mslex==1.3.0 # via oslex multidict==6.7.0 @@ -202,6 +207,7 @@ numpy==2.3.5 # chromadb # contourpy # matplotlib + # moonshine-voice # onnxruntime # pandas # rustworkx @@ -264,6 +270,7 @@ pip-tools==7.5.2 # via -r requirements/requirements-dev.in platformdirs==4.5.0 # via + # moonshine-voice # textual # virtualenv playwright==1.56.0 @@ -381,6 +388,7 @@ requests==2.32.5 # -r requirements/requirements.in # huggingface-hub # kubernetes + # moonshine-voice # requests-oauthlib requests-oauthlib==2.0.0 # via kubernetes @@ -416,7 +424,9 @@ sniffio==1.3.1 socksio==1.0.0 # via -r requirements/requirements.in sounddevice==0.5.3 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # moonshine-voice soundfile==0.13.1 # via -r requirements/requirements.in soupsieve==2.8 @@ -440,6 +450,7 @@ tqdm==4.67.1 # -r requirements/requirements.in # chromadb # huggingface-hub + # moonshine-voice tree-sitter==0.25.2 # via # -r requirements/requirements.in diff --git a/requirements/requirements.in b/requirements/requirements.in index c83aca94357..3f17fdb5fe1 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -56,6 +56,9 @@ pydub>=0.25.1 sounddevice>=0.5.2 soundfile>=0.13.1 +# on-device speech-to-text for the voice command (Moonshine small English model) +moonshine-voice>=0.1.5 + # images (PIL import) pillow>=11.3.0 diff --git a/tests/basic/test_background_commands.py b/tests/basic/test_background_commands.py index 128d34a97cc..5ca98746186 100644 --- a/tests/basic/test_background_commands.py +++ b/tests/basic/test_background_commands.py @@ -56,72 +56,128 @@ def readline(self): def test_circular_buffer_basic_operations(): """Test basic CircularBuffer operations: append, get_all, clear.""" - buffer = CircularBuffer(max_size=10) - - # Test append and get_all + buffer = CircularBuffer(max_size=11) buffer.append("Hello") buffer.append(" ") buffer.append("World") assert buffer.get_all() == "Hello World" + assert buffer.size() == 11 - # Test clear buffer.clear() assert buffer.get_all() == "" assert buffer.size() == 0 + assert buffer.total_added == 0 - # Test that buffer is empty after clear buffer.append("New") assert buffer.get_all() == "New" + assert buffer.size() == 3 def test_circular_buffer_max_size(): - """Test that CircularBuffer respects max_size limit.""" + """Evict characters, even when overflow trims only part of an old chunk.""" buffer = CircularBuffer(max_size=5) + buffer.append("12345") + assert buffer.get_all() == "12345" + assert buffer.size() == 5 - # Add content that exceeds max_size - buffer.append("12345") # Exactly max_size - buffer.append("67890") # This should push out "12345" + buffer.append("67") + assert buffer.get_all() == "34567" + assert buffer.size() == 5 - # Buffer should contain both strings (2 elements, each 5 chars) - # deque with maxlen=5 will keep up to 5 elements, not 5 characters - assert buffer.get_all() == "1234567890" + buffer.append("89012") + assert buffer.get_all() == "89012" + assert buffer.size() == 5 + assert buffer.total_added == 12 - # Test with many small chunks buffer.clear() for i in range(10): buffer.append(str(i)) + assert buffer.size() <= 5 - # Should only keep last 5 elements: "5", "6", "7", "8", "9" assert buffer.get_all() == "56789" def test_circular_buffer_get_new_output(): - """Test CircularBuffer.get_new_output method.""" + """Incremental positions include evicted characters, but output stays bounded.""" buffer = CircularBuffer(max_size=10) - - # Add some initial content buffer.append("Hello") buffer.append(" World") - # Get new output from position 0 (should get everything) new_output, new_position = buffer.get_new_output(0) - assert new_output == "Hello World" - assert new_position == 11 # "Hello World" is 11 characters + assert new_output == "ello World" + assert new_position == 11 - # Add more content buffer.append("!") - - # Get new output from previous position new_output, new_position = buffer.get_new_output(new_position) assert new_output == "!" assert new_position == 12 - # Try to get new output from current position (should be empty) new_output, new_position = buffer.get_new_output(new_position) assert new_output == "" assert new_position == 12 + buffer.append("0123456789ABCDE") + assert buffer.get_new_output(new_position) == ("56789ABCDE", 27) + + +def test_circular_buffer_oversized_append(): + """A single large chunk must not bypass the character limit.""" + buffer = CircularBuffer(max_size=4096) + buffer.append("old output") + text = "0123456789" * 10_000 + buffer.append(text) + + assert buffer.size() == 4096 + assert buffer.get_all() == text[-4096:] + assert buffer.get_new_output(0) == (text[-4096:], len("old output") + len(text)) + + +def test_circular_buffer_empty_append_preserves_full_buffer(): + buffer = CircularBuffer(max_size=3) + buffer.append("abc") + buffer.append("") + + assert buffer.get_all() == "abc" + assert buffer.size() == 3 + assert buffer.total_added == 3 + + +def test_circular_buffer_zero_capacity(): + buffer = CircularBuffer(max_size=0) + buffer.append("discarded") + buffer.append("") + + assert buffer.get_all() == "" + assert buffer.size() == 0 + assert buffer.get_new_output(0) == ("", 9) + + +def test_circular_buffer_unicode_characters(): + """Capacity measures Python characters rather than encoded bytes.""" + buffer = CircularBuffer(max_size=3) + buffer.append("aé中") + buffer.append("🙂ß") + + assert buffer.get_all() == "中🙂ß" + assert buffer.size() == 3 + assert buffer.get_new_output(0) == ("中🙂ß", 5) + + +def test_circular_buffer_get_all_clear_resets_accounting(): + buffer = CircularBuffer(max_size=3) + buffer.append("abcde") + + assert buffer.get_all(clear=True) == "cde" + assert buffer.size() == 0 + assert buffer.total_added == 0 + assert buffer.get_new_output(0) == ("", 0) + + buffer.append("xy") + assert buffer.get_all() == "xy" + assert buffer.size() == 2 + assert buffer.get_new_output(0) == ("xy", 2) + def test_background_process_basic(): """Test basic BackgroundProcess functionality.""" diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 07b02a92df1..0ae2334d9fb 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -735,7 +735,7 @@ async def mock_send(*args, **kwargs): saved_diffs = [] - async def mock_get_commit_message(diffs, context, user_language=None): + async def mock_get_commit_message(diffs, context, user_language=None, coder=None): saved_diffs.append(diffs) return "commit message" @@ -815,7 +815,7 @@ async def mock_send(*args, **kwargs): saved_diffs = [] - async def mock_get_commit_message(diffs, context, user_language=None): + async def mock_get_commit_message(diffs, context, user_language=None, coder=None): saved_diffs.append(diffs) return "commit message" @@ -1761,7 +1761,7 @@ async def test_auto_commit_with_none_content_message(self): # The context for commit message will be generated from cur_messages. # This call should not raise an exception due to `content: None`. - async def mock_get_commit_message(diffs, context, user_language=None): + async def mock_get_commit_message(diffs, context, user_language=None, coder=None): assert "USER: do a thing" in context # None becomes empty string. assert "ASSISTANT: \n" in context diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index cd9fedb374e..c367cafefd1 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -136,9 +136,8 @@ def test_command_paths_none_does_not_warn(self): args = SimpleNamespace(command_paths=None) with mock.patch.object(io, "tool_warning") as tool_warning: - commands = Commands(io, coder=None, args=args) + Commands(io, coder=None, args=args) - self.assertEqual(commands.custom_commands, []) tool_warning.assert_not_called() async def test_cmd_copy_pyperclip_exception(self): diff --git a/tests/basic/test_history.py b/tests/basic/test_history.py index 542a65efed6..35f0a8530bc 100644 --- a/tests/basic/test_history.py +++ b/tests/basic/test_history.py @@ -42,7 +42,7 @@ def test_tokenize(self): self.assertEqual(tokenized, [(2, messages[0]), (2, messages[1])]) async def test_summarize_all(self): - self.mock_model.simple_send_with_retries.return_value = "This is a summary" + self.mock_model.simple_send_with_retries.return_value = ("This is a summary", None) messages = [ {"role": "user", "content": "Hello world"}, {"role": "assistant", "content": "Hi there"}, @@ -87,7 +87,9 @@ async def test_fallback_to_second_model(self): mock_model2 = mock.Mock(spec=Model) mock_model2.name = "gpt-3.5-turbo" - mock_model2.simple_send_with_retries = mock.Mock(return_value="Summary from Model 2") + mock_model2.simple_send_with_retries = mock.Mock( + return_value=("Summary from Model 2", None) + ) mock_model2.info = {"max_input_tokens": 4096} mock_model2.token_count = lambda msg: len(msg["content"].split()) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 8ea28c0842a..fe26a786fab 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -378,6 +378,7 @@ def test_suppress_release_notes_prompt_with_yes_always(dummy_io, git_temp_dir, m mock_input_output.return_value.offer_url = AsyncMock() mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) + mocker.patch("cecli.onboarding.select_default_model", new=AsyncMock(return_value="gpt-4o")) main(["--exit", "--yes-always"], **dummy_io) mock_input_output.return_value.offer_url.assert_not_called() @@ -388,6 +389,7 @@ def test_shows_release_notes_prompt_on_first_run(dummy_io, git_temp_dir, mocker) mock_input_output.return_value.offer_url = AsyncMock() mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) + mocker.patch("cecli.onboarding.select_default_model", new=AsyncMock(return_value="gpt-4o")) main(["--exit"], **dummy_io) mock_input_output.return_value.offer_url.assert_called_once() @@ -399,6 +401,7 @@ def test_explicit_show_release_notes_with_yes_always(dummy_io, git_temp_dir, moc mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) mocker.patch("webbrowser.open") + mocker.patch("cecli.onboarding.select_default_model", new=AsyncMock(return_value="gpt-4o")) main(["--exit", "--yes-always", "--show-release-notes"], **dummy_io) # The explicit --show-release-notes code path uses webbrowser.open directly, not offer_url mock_input_output.return_value.offer_url.assert_not_called() @@ -883,10 +886,10 @@ def test_invalid_edit_format(dummy_io, git_temp_dir, mocker, capsys): @pytest.mark.parametrize( "api_key_env,expected_model_substr", [ - ("ANTHROPIC_API_KEY", "sonnet"), - ("DEEPSEEK_API_KEY", "deepseek"), + ("ANTHROPIC_API_KEY", "anthropic/claude-sonnet-5"), + ("DEEPSEEK_API_KEY", "deepseek-v4-flash"), ("OPENROUTER_API_KEY", "openrouter/"), - ("OPENAI_API_KEY", "gpt-4"), + ("OPENAI_API_KEY", "gpt-5.6-luna"), ("GEMINI_API_KEY", "gemini"), ], ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], @@ -929,11 +932,12 @@ def test_default_model_selection_oauth_fallback(dummy_io, git_temp_dir, mocker): saved_keys[key] = os.environ[key] del os.environ[key] try: - mock_offer_oauth = mocker.patch("cecli.onboarding.offer_openrouter_oauth") - mock_offer_oauth.return_value = None + mock_onboarding = mocker.patch( + "cecli.helpers.onboarding.run_onboarding", new=AsyncMock(return_value=None) + ) result = main(["--exit", "--yes-always"], **dummy_io) assert result == 1 - mock_offer_oauth.assert_called_once() + mock_onboarding.assert_called_once() finally: for key, value in saved_keys.items(): os.environ[key] = value diff --git a/tests/basic/test_onboarding.py b/tests/basic/test_onboarding.py index b3b753db723..a409012b4ab 100644 --- a/tests/basic/test_onboarding.py +++ b/tests/basic/test_onboarding.py @@ -85,49 +85,49 @@ def test_try_select_default_model_no_keys(self, mock_check_tier): @patch.dict(os.environ, {"OPENROUTER_API_KEY": "or_key"}, clear=True) def test_try_select_default_model_openrouter_free(self, mock_check_tier): """Test OpenRouter free model selection.""" - assert try_to_select_default_model() == "openrouter/deepseek/deepseek-r1:free" + assert try_to_select_default_model() == "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free" mock_check_tier.assert_called_once_with("or_key") @patch("cecli.onboarding.check_openrouter_tier", return_value=False) # Assume paid tier @patch.dict(os.environ, {"OPENROUTER_API_KEY": "or_key"}, clear=True) def test_try_select_default_model_openrouter_paid(self, mock_check_tier): """Test OpenRouter paid model selection.""" - assert try_to_select_default_model() == "openrouter/anthropic/claude-sonnet-4" + assert try_to_select_default_model() == "openrouter/anthropic/claude-sonnet-5" mock_check_tier.assert_called_once_with("or_key") @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "an_key"}, clear=True) def test_try_select_default_model_anthropic(self, mock_check_tier): """Test Anthropic model selection.""" - assert try_to_select_default_model() == "sonnet" + assert try_to_select_default_model() == "anthropic/claude-sonnet-5" mock_check_tier.assert_not_called() @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"DEEPSEEK_API_KEY": "ds_key"}, clear=True) def test_try_select_default_model_deepseek(self, mock_check_tier): """Test Deepseek model selection.""" - assert try_to_select_default_model() == "deepseek" + assert try_to_select_default_model() == "deepseek-v4-flash" mock_check_tier.assert_not_called() @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"OPENAI_API_KEY": "oa_key"}, clear=True) def test_try_select_default_model_openai(self, mock_check_tier): """Test OpenAI model selection.""" - assert try_to_select_default_model() == "gpt-4o" + assert try_to_select_default_model() == "gpt-5.6-luna" mock_check_tier.assert_not_called() @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"GEMINI_API_KEY": "gm_key"}, clear=True) def test_try_select_default_model_gemini(self, mock_check_tier): """Test Gemini model selection.""" - assert try_to_select_default_model() == "gemini/gemini-2.5-pro-exp-03-25" + assert try_to_select_default_model() == "gemini/gemini-3.8-flash" mock_check_tier.assert_not_called() @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"VERTEXAI_PROJECT": "vx_proj"}, clear=True) def test_try_select_default_model_vertex(self, mock_check_tier): """Test Vertex AI model selection.""" - assert try_to_select_default_model() == "vertex_ai/gemini-2.5-pro-exp-03-25" + assert try_to_select_default_model() == "gemini/gemini-3.8-flash" mock_check_tier.assert_not_called() @patch("cecli.onboarding.check_openrouter_tier", return_value=False) # Paid @@ -136,14 +136,14 @@ def test_try_select_default_model_vertex(self, mock_check_tier): ) def test_try_select_default_model_priority_openrouter(self, mock_check_tier): """Test OpenRouter key takes priority.""" - assert try_to_select_default_model() == "openrouter/anthropic/claude-sonnet-4" + assert try_to_select_default_model() == "openrouter/anthropic/claude-sonnet-5" mock_check_tier.assert_called_once_with("or_key") @patch("cecli.onboarding.check_openrouter_tier") @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "an_key", "OPENAI_API_KEY": "oa_key"}, clear=True) def test_try_select_default_model_priority_anthropic(self, mock_check_tier): """Test Anthropic key takes priority over OpenAI.""" - assert try_to_select_default_model() == "sonnet" + assert try_to_select_default_model() == "anthropic/claude-sonnet-5" mock_check_tier.assert_not_called() @patch("socketserver.TCPServer") @@ -306,14 +306,14 @@ async def test_select_default_model_found_via_env(self, mock_offer_oauth, mock_t ) mock_offer_oauth.assert_not_called() - @patch( - "cecli.onboarding.try_to_select_default_model", side_effect=[None, None] - ) # Fails first, fails after oauth attempt - @patch( - "cecli.onboarding.offer_openrouter_oauth", return_value=False - ) # OAuth offered but fails/declined - async def test_select_default_model_no_keys_oauth_fail(self, mock_offer_oauth, mock_try_select): - """Test select_default_model offers OAuth when no keys, but OAuth fails.""" + async def test_select_default_model_no_keys_onboarding_fail(self, mocker): + """Test select_default_model falls back to onboarding, which returns no model.""" + mock_onboarding = mocker.patch( + "cecli.helpers.onboarding.run_onboarding", new=AsyncMock(return_value=None) + ) + mock_try_select = mocker.patch( + "cecli.onboarding.try_to_select_default_model", side_effect=[None] + ) args = argparse.Namespace(model=None) io_mock = DummyIO() io_mock.tool_warning = MagicMock() @@ -322,42 +322,36 @@ async def test_select_default_model_no_keys_oauth_fail(self, mock_offer_oauth, m selected_model = await select_default_model(args, io_mock) assert selected_model is None - assert mock_try_select.call_count == 2 # Called before and after oauth attempt - mock_offer_oauth.assert_called_once_with(io_mock) + assert mock_try_select.call_count == 1 + mock_onboarding.assert_called_once_with(io_mock) io_mock.tool_warning.assert_called_once_with( "No LLM model was specified and no API keys were provided." ) io_mock.offer_url.assert_called_once() # Should offer docs URL - @patch( - "cecli.onboarding.try_to_select_default_model", - side_effect=[None, "openrouter/deepseek/deepseek-r1:free"], - ) # Fails first, succeeds after oauth - @patch( - "cecli.onboarding.offer_openrouter_oauth", return_value=True - ) # OAuth offered and succeeds - async def test_select_default_model_no_keys_oauth_success( - self, mock_offer_oauth, mock_try_select - ): - """Test select_default_model offers OAuth, which succeeds.""" + async def test_select_default_model_no_keys_onboarding_success(self, mocker): + """Test select_default_model returns the model chosen by onboarding.""" + mock_onboarding = mocker.patch( + "cecli.helpers.onboarding.run_onboarding", + new=AsyncMock(return_value="openrouter/deepseek/deepseek-r1:free"), + ) + mock_try_select = mocker.patch( + "cecli.onboarding.try_to_select_default_model", side_effect=[None] + ) args = argparse.Namespace(model=None) io_mock = DummyIO() io_mock.tool_warning = MagicMock() + io_mock.offer_url = AsyncMock() selected_model = await select_default_model(args, io_mock) assert selected_model == "openrouter/deepseek/deepseek-r1:free" - assert mock_try_select.call_count == 2 # Called before and after oauth - mock_offer_oauth.assert_called_once_with(io_mock) - # Only one warning is expected: "No LLM model..." - assert io_mock.tool_warning.call_count == 1 + assert mock_try_select.call_count == 1 + mock_onboarding.assert_called_once_with(io_mock) io_mock.tool_warning.assert_called_once_with( "No LLM model was specified and no API keys were provided." ) - # The second call to try_select finds the model, so the *outer* function logs the usage. - # Note: The warning comes from the second call within select_default_model, - # not try_select itself. - # We verify the final state and model returned. + io_mock.offer_url.assert_not_called() # --- Tests for offer_openrouter_oauth --- @patch("cecli.onboarding.start_openrouter_oauth_flow", return_value="new_or_key") diff --git a/tests/basic/test_reasoning.py b/tests/basic/test_reasoning.py index 86186b2ce71..3e003c9f728 100644 --- a/tests/basic/test_reasoning.py +++ b/tests/basic/test_reasoning.py @@ -623,4 +623,4 @@ async def test_simple_send_with_retries_removes_reasoning(self): expected = """Here is some text And this text should remain""" - assert result == expected + assert result[0] == expected diff --git a/tests/basic/test_repo.py b/tests/basic/test_repo.py index c6b3c341c09..9546146dfeb 100644 --- a/tests/basic/test_repo.py +++ b/tests/basic/test_repo.py @@ -131,7 +131,7 @@ def test_diffs_between_commits(self): @patch("cecli.models.Model.simple_send_with_retries", new_callable=AsyncMock) async def test_get_commit_message(self, mock_send): - mock_send.side_effect = ["", "a good commit message"] + mock_send.side_effect = [("", None), ("a good commit message", None)] model1 = Model("gpt-3.5-turbo") model2 = Model("gpt-4") @@ -159,7 +159,7 @@ async def test_get_commit_message(self, mock_send): @patch("cecli.models.Model.simple_send_with_retries", new_callable=AsyncMock) async def test_get_commit_message_strip_quotes(self, mock_send): - mock_send.return_value = '"a good commit message"' + mock_send.return_value = ('"a good commit message"', None) with GitTemporaryDirectory(): repo = GitRepo(InputOutput(), None, None, models=[self.GPT35]) @@ -171,7 +171,7 @@ async def test_get_commit_message_strip_quotes(self, mock_send): @patch("cecli.models.Model.simple_send_with_retries", new_callable=AsyncMock) async def test_get_commit_message_no_strip_unmatched_quotes(self, mock_send): - mock_send.return_value = 'a good "commit message"' + mock_send.return_value = ('a good "commit message"', None) with GitTemporaryDirectory(): repo = GitRepo(InputOutput(), None, None, models=[self.GPT35]) @@ -183,7 +183,7 @@ async def test_get_commit_message_no_strip_unmatched_quotes(self, mock_send): @patch("cecli.models.Model.simple_send_with_retries", new_callable=AsyncMock) async def test_get_commit_message_with_custom_prompt(self, mock_send): - mock_send.return_value = "Custom commit message" + mock_send.return_value = ("Custom commit message", None) custom_prompt = "Generate a commit message in the style of Shakespeare" with GitTemporaryDirectory(): @@ -627,7 +627,7 @@ def test_subtree_only(self): @patch("cecli.models.Model.simple_send_with_retries") async def test_noop_commit(self, mock_send): - mock_send.return_value = '"a good commit message"' + mock_send.return_value = ('"a good commit message"', None) with GitTemporaryDirectory(): # new repo @@ -699,7 +699,7 @@ async def test_get_commit_message_uses_system_prompt_prefix(self, mock_send): Verify that GitRepo.get_commit_message() prepends the model.system_prompt_prefix to the system prompt sent to the LLM. """ - mock_send.return_value = "good commit message" + mock_send.return_value = ("good commit message", None) prefix = "MY-CUSTOM-PREFIX" model = Model("gpt-3.5-turbo") diff --git a/tests/basic/test_sendchat.py b/tests/basic/test_sendchat.py index 9b544597260..e11f0bc5bd0 100644 --- a/tests/basic/test_sendchat.py +++ b/tests/basic/test_sendchat.py @@ -93,7 +93,8 @@ async def test_simple_send_with_retries_passes_tools_from_coder(self, mock_compl self.mock_messages, coder=coder ) - assert result == "summary" + content, _ = result + assert content == "summary" # send_completion must receive the coder's tools so summarization / # observation requests share the same (messages + tools) prefix as the main chat @@ -110,7 +111,7 @@ async def test_simple_send_attribute_error(self, mock_completion): # Should return None on AttributeError result = await Model(self.mock_model).simple_send_with_retries(self.mock_messages) - assert result is None + assert result == (None, None) @patch("cecli.llm.litellm.acompletion") @patch("builtins.print") @@ -127,7 +128,7 @@ async def test_simple_send_non_retryable_error(self, mock_print, mock_completion model.verbose = True result = await model.simple_send_with_retries(self.mock_messages) - assert result is None + assert result == (None, None) # Should only print the error message assert mock_print.call_count > 0 diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py index 9271f8f426e..1a6171b9c30 100644 --- a/tests/basic/test_skills.py +++ b/tests/basic/test_skills.py @@ -134,8 +134,11 @@ def test_create_and_parse_skill(self): assert "test-skill" not in manager._loaded_skills assert manager._loaded_skills == set() - def test_skill_summary_loader(self): + def test_skill_summary_loader(self, monkeypatch): """Test the skill_summary_loader function.""" + # Isolate from the real ~/.cecli/skills directory so the test is + # deterministic regardless of what's configured on the device. + monkeypatch.setattr(Path, "home", lambda: Path(self.temp_dir)) # Create a skill directory structure skill_dir = Path(self.temp_dir) / "test-skill" skill_dir.mkdir() @@ -154,18 +157,17 @@ def test_skill_summary_loader(self): # Test the skill summary loader (class method) summary = SkillsManager.skill_summary_loader([self.temp_dir]) - # Check that the summary contains expected information - assert "Found 1 skill(s)" in summary + # Check that the temp-dir skill is visible in the summary assert "Skill: test-skill" in summary assert "Description: A test skill for validation" in summary # Test with include list summary = SkillsManager.skill_summary_loader([self.temp_dir], include_list=["test-skill"]) - assert "Found 1 skill(s)" in summary + assert "Skill: test-skill" in summary - # Test with exclude list + # Test with exclude list - the temp-dir skill should be excluded summary = SkillsManager.skill_summary_loader([self.temp_dir], exclude_list=["test-skill"]) - assert "No skills found" in summary + assert "Skill: test-skill" not in summary def test_resolve_skill_directories(self): """Test the resolve_skill_directories function.""" diff --git a/tests/basic/test_spinner.py b/tests/basic/test_spinner.py index e66965bafd8..07d345b8f85 100644 --- a/tests/basic/test_spinner.py +++ b/tests/basic/test_spinner.py @@ -22,10 +22,10 @@ def mock_model(): ) model.token_count = MagicMock(return_value=10) model.info = {"max_input_tokens": 100000} - model.simple_send_with_retries = MagicMock(return_value="test commit") + model.simple_send_with_retries = MagicMock(return_value=("test commit", None)) async def _async_simple_send(*args, **kwargs): - return "test commit" + return ("test commit", None) model.simple_send_with_retries = _async_simple_send return model diff --git a/tests/basic/test_voice.py b/tests/basic/test_voice.py index ef878da4583..80e4262b434 100644 --- a/tests/basic/test_voice.py +++ b/tests/basic/test_voice.py @@ -24,14 +24,17 @@ def mock_soundfile(): @pytest.fixture -def mock_litellm(): - mock_llm = MagicMock() - mock_llm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) - return mock_llm +def mock_audio_libs(): + """Expose fake sounddevice/soundfile modules so ``Voice()`` can be built.""" + with patch.dict( + "sys.modules", + {"sounddevice": MagicMock(), "soundfile": MagicMock()}, + ): + yield @pytest.mark.asyncio -async def test_voice_init_default(): +async def test_voice_init_default(mock_audio_libs): """Test Voice initialization with default parameters.""" voice = Voice() assert voice.audio_format == "wav" @@ -40,7 +43,7 @@ async def test_voice_init_default(): @pytest.mark.asyncio -async def test_voice_init_with_device(): +async def test_voice_init_with_device(mock_audio_libs): """Test Voice initialization with specific device name.""" voice = Voice(device_name="test_device", audio_format="mp3") assert voice.device_name == "test_device" @@ -48,7 +51,7 @@ async def test_voice_init_with_device(): @pytest.mark.asyncio -async def test_record_and_transcribe_success(): +async def test_record_and_transcribe_success(mock_audio_libs): """Test successful recording and transcription.""" voice = Voice() @@ -79,8 +82,8 @@ async def test_record_and_transcribe_success(): @pytest.mark.asyncio -async def test_record_and_transcribe_exception(): - """Test that exceptions in transcription are caught and return None.""" +async def test_record_and_transcribe_exception(mock_audio_libs): + """Test that exceptions in transcription propagate to the caller.""" voice = Voice() # Mock the executor's run_in_executor to raise an exception @@ -93,13 +96,12 @@ async def test_record_and_transcribe_exception(): ): mock_loop.return_value.run_in_executor = MagicMock(return_value=mock_future) - result = await voice.record_and_transcribe() - - assert result is None + with pytest.raises(Exception, match="Test error"): + await voice.record_and_transcribe() @pytest.mark.asyncio -async def test_record_and_transcribe_with_device(): +async def test_record_and_transcribe_with_device(mock_audio_libs): """Test recording with specific device name.""" voice = Voice(device_name="test_device") @@ -131,12 +133,10 @@ def test_run_record_process_device_selection(): mock_sd = MagicMock() mock_sf = MagicMock() mock_sf.SoundFile = MagicMock() - mock_litellm = MagicMock() - mock_litellm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) with ( patch.dict("sys.modules", {"sounddevice": mock_sd, "soundfile": mock_sf}), - patch("cecli.llm.litellm", mock_litellm), + patch("cecli.voice._transcribe_local", return_value="Test transcription"), patch("tempfile.NamedTemporaryFile") as mock_tempfile, patch("builtins.open", mock_open()), patch("os.remove"), @@ -223,11 +223,7 @@ def query_devices_side_effect(device_id=None, kind=None): mock_sf.SoundFile.return_value.__enter__.return_value.write = MagicMock() - # Mock litellm - mock_litellm = MagicMock() - mock_litellm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) - - with patch("cecli.llm.litellm", mock_litellm): + with patch("cecli.voice._transcribe_local", return_value="Test transcription"): # Mock stdin.readline to simulate user pressing ENTER with patch("sys.stdin.readline", return_value=""): from cecli.voice import _run_record_process @@ -236,3 +232,199 @@ def query_devices_side_effect(device_id=None, kind=None): # Should still work with device_id=None assert result == "Test transcription" + + +def test_resolve_moonshine_language(): + """Test language resolution precedence for the Moonshine model.""" + from cecli.voice import resolve_moonshine_language + + # Explicit voice-language value wins. + assert resolve_moonshine_language("es", "English") == "es" + assert resolve_moonshine_language("english", None) == "en" + + # Falls back to the detected user/chat language. + assert resolve_moonshine_language(None, "Spanish") == "es" + assert resolve_moonshine_language(None, "Chinese") == "zh" + assert resolve_moonshine_language(None, "Mandarin") == "zh" + + # Unsupported/absent languages fall back to English. + assert resolve_moonshine_language(None, "Russian") == "en" + assert resolve_moonshine_language(None, None) == "en" + assert resolve_moonshine_language("", None) == "en" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [None, False, 0, "", lambda: None]) +async def test_stop_bridge_accepts_any_payload(payload): + import queue + + from cecli.voice import _bridge_stop_queue + + source = queue.Queue() + destination = queue.Queue() + task = asyncio.create_task(_bridge_stop_queue(source, destination)) + await asyncio.sleep(0) + assert not task.done() + source.put(payload) + await asyncio.wait_for(task, 1) + assert destination.get_nowait() is None + assert source.empty() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("worker_error", [False, True]) +async def test_cancellation_waits_before_manager_shutdown(mock_audio_libs, worker_error): + import queue + + loop = asyncio.get_running_loop() + worker_future = loop.create_future() + manager = MagicMock() + worker_stop_queue = queue.Queue() + manager.Queue.return_value = worker_stop_queue + + with ( + patch("multiprocessing.Manager", return_value=manager), + patch("cecli.voice.ProcessPoolExecutor"), + patch("cecli.voice.sys.stdin") as stdin, + patch.object(loop, "run_in_executor", return_value=worker_future) as submit, + ): + stdin.fileno.side_effect = AssertionError("queue mode accessed stdin") + voice = Voice() + task = asyncio.create_task(voice.record_and_transcribe(stop_queue=queue.Queue())) + await asyncio.sleep(0) + submit.assert_called_once() + assert submit.call_args.args[2] is None + assert submit.call_args.args[-1] is worker_stop_queue + task.cancel() + await asyncio.sleep(0) + assert worker_stop_queue.get_nowait() is None + assert not task.done() + assert not worker_future.cancelled() + manager.shutdown.assert_not_called() + task.cancel() + await asyncio.sleep(0) + assert not task.done() + manager.shutdown.assert_not_called() + + if worker_error: + worker_future.set_exception(RuntimeError("worker failed while stopping")) + else: + worker_future.set_result("finished") + + with pytest.raises(asyncio.CancelledError): + await task + + manager.shutdown.assert_called_once() + stdin.fileno.assert_not_called() + voice.close() + voice._executor.shutdown.assert_called_once_with(wait=True) + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("payload", [None, False]) +def test_worker_queue_mode_skips_stdin(streaming, payload): + import queue + from types import SimpleNamespace + + from cecli.voice import _STREAM_END, _run_record_process + + stop_queue = queue.Queue() + stop_queue.put(payload) + text_queue = queue.Queue() + status_queue = queue.Queue() + sounddevice = MagicMock() + sounddevice.query_devices.return_value = {"default_samplerate": 16000} + transcriber = MagicMock() + transcriber.stop.return_value = SimpleNamespace(lines=[SimpleNamespace(text="hello")]) + + with ( + patch.dict( + "sys.modules", + { + "sounddevice": sounddevice, + "soundfile": MagicMock(), + "moonshine_voice.transcriber": MagicMock(), + }, + ), + patch("cecli.voice.sys.stdin") as stdin, + patch("cecli.voice.os.dup") as dup, + patch("cecli.voice.os.fdopen") as fdopen, + patch("cecli.voice.tempfile.NamedTemporaryFile") as tempfile, + patch("cecli.voice.os.path.exists", return_value=False), + patch("cecli.voice._transcribe_local", return_value="hello"), + patch("cecli.voice._build_transcriber", return_value=transcriber), + ): + tempfile.return_value.__enter__.return_value.name = "unused.wav" + result = _run_record_process( + None, + "wav", + None, + None, + "en" if streaming else "ko", + text_queue, + status_queue, + "ctrl+r", + stop_queue, + ) + assert result == "hello" + assert stop_queue.empty() + stdin.fileno.assert_not_called() + stdin.readline.assert_not_called() + dup.assert_not_called() + fdopen.assert_not_called() + assert text_queue.get_nowait() == _STREAM_END + assert "ctrl+r" in status_queue.get_nowait() + + if streaming: + transcriber.close.assert_called_once() + transcriber.stop.assert_called_once() + tempfile.assert_not_called() + + +@pytest.mark.parametrize("failure", ["start", "record", "stop"]) +def test_streaming_closes_transcriber_on_errors(failure): + import queue + + from cecli.voice import _record_and_stream + + sounddevice = MagicMock() + transcriber = MagicMock() + stop_queue = queue.Queue() + stop_queue.put(None) + + if failure == "record": + sounddevice.InputStream.return_value.__enter__.side_effect = RuntimeError("record") + else: + getattr(transcriber, failure).side_effect = RuntimeError(failure) + + with ( + patch.dict( + "sys.modules", + { + "sounddevice": sounddevice, + "moonshine_voice.transcriber": MagicMock(), + }, + ), + patch("cecli.voice._build_transcriber", return_value=transcriber), + ): + with pytest.raises(RuntimeError, match=failure): + _record_and_stream( + queue.Queue(), + MagicMock(), + 16000, + None, + "en", + queue.Queue(), + status_queue=queue.Queue(), + stop_queue=stop_queue, + ) + + transcriber.close.assert_called_once() + + +def test_wait_for_stop_preserves_cli_readline(): + from cecli.voice import _wait_for_stop + + with patch("cecli.voice.sys.stdin") as stdin: + _wait_for_stop(None) + stdin.readline.assert_called_once_with() diff --git a/tests/coders/test_rate_limit.py b/tests/coders/test_rate_limit.py new file mode 100644 index 00000000000..8cb52372efa --- /dev/null +++ b/tests/coders/test_rate_limit.py @@ -0,0 +1,125 @@ +"""Unit tests for the token-per-minute rate limiter in base_coder.UsageMeta.""" + +import time +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def reset_usage(): + """Reset the shared UsageMeta token-usage buffer before each test.""" + from cecli.coders.base_coder import UsageMeta + + UsageMeta._reset_token_usage() + UsageMeta._total_tokens_sent = 0 + yield UsageMeta + + +def _dummy(model="model-a", limit=2000000): + model_obj = SimpleNamespace(name=model) + return SimpleNamespace( + args=SimpleNamespace(tokens_per_minute=limit), + get_active_model=lambda: model_obj, + ) + + +def test_buffer_records_and_reports_per_model(reset_usage): + UsageMeta = reset_usage + now = time.time() + + assert UsageMeta._get_token_usage_stats("model-a") == (0, 0, 0) + + UsageMeta._record_token_usage("model-a", 1000, now=now) + UsageMeta._record_token_usage("model-a", 500, now=now) + # A different model is isolated. + UsageMeta._record_token_usage("model-b", 777, now=now) + + tokens_last_min, max_request, rpm = UsageMeta._get_token_usage_stats("model-a") + assert tokens_last_min == 1500 + assert max_request == 1000 + assert rpm == 2 + + tokens_last_min_b, _, rpm_b = UsageMeta._get_token_usage_stats("model-b") + assert tokens_last_min_b == 777 + assert rpm_b == 1 + + +def test_buffer_purges_old_entries_per_model(reset_usage): + UsageMeta = reset_usage + now = time.time() + + UsageMeta._record_token_usage("model-a", 1000, now=now) + UsageMeta._record_token_usage("model-a", 999, now=now - 61.0) # stale + + tokens_last_min, _, rpm = UsageMeta._get_token_usage_stats("model-a") + assert tokens_last_min == 1000 + assert rpm == 1 + + +def test_buffer_reset_all_and_single_model(reset_usage): + UsageMeta = reset_usage + now = time.time() + UsageMeta._record_token_usage("model-a", 1000, now=now) + UsageMeta._record_token_usage("model-b", 777, now=now) + + UsageMeta._reset_token_usage("model-a") + assert UsageMeta._get_token_usage_stats("model-a") == (0, 0, 0) + assert UsageMeta._get_token_usage_stats("model-b")[0] == 777 + + UsageMeta._reset_token_usage() + assert UsageMeta._get_token_usage_stats("model-b") == (0, 0, 0) + + +def test_dynamic_sleep_no_burst(reset_usage): + from cecli.coders.base_coder import Coder + + UsageMeta = reset_usage + # One 500k request at 1 rpm stays well under budget -> no sleep. + UsageMeta._record_token_usage("model-a", 500000, now=time.time()) + assert Coder.calculate_dynamic_sleep(_dummy()) == 0.0 + + +def test_dynamic_sleep_burst_rounds_to_quarter(reset_usage): + from cecli.coders.base_coder import Coder + + UsageMeta = reset_usage + now = time.time() + # 30 requests of 500k tokens in the window -> far over the 1.8M budget. + for _ in range(30): + UsageMeta._record_token_usage("model-a", 500000, now=now) + + sleep = Coder.calculate_dynamic_sleep(_dummy()) + assert sleep > 0 + assert abs((sleep / 0.25) - round(sleep / 0.25)) < 1e-6 + assert sleep <= 60.0 + + +def test_dynamic_sleep_isolated_per_model(reset_usage): + from cecli.coders.base_coder import Coder + + UsageMeta = reset_usage + now = time.time() + # Only model-b is saturated; model-a must be unaffected. + for _ in range(30): + UsageMeta._record_token_usage("model-b", 500000, now=now) + + assert Coder.calculate_dynamic_sleep(_dummy(model="model-a")) == 0.0 + assert Coder.calculate_dynamic_sleep(_dummy(model="model-b")) > 0.0 + + +def test_dynamic_sleep_disabled_with_zero_limit(reset_usage): + from cecli.coders.base_coder import Coder + + UsageMeta = reset_usage + UsageMeta._record_token_usage("model-a", 500000, now=time.time()) + assert Coder.calculate_dynamic_sleep(_dummy(limit=0)) == 0.0 + + +def test_dynamic_sleep_missing_args(reset_usage): + from cecli.coders.base_coder import Coder + + # UsageMeta = reset_usage + # No args -> falls back to the default limit; empty buffer -> no sleep. + coder = SimpleNamespace(args=None, get_active_model=lambda: SimpleNamespace(name="model-a")) + assert Coder.calculate_dynamic_sleep(coder) == 0.0 diff --git a/tests/commands/test_voice.py b/tests/commands/test_voice.py new file mode 100644 index 00000000000..1596bff7f7a --- /dev/null +++ b/tests/commands/test_voice.py @@ -0,0 +1,194 @@ +import asyncio +import queue +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from cecli.commands.voice import VoiceCommand +from cecli.voice import SoundDeviceError + + +@pytest.fixture +def voice_context(): + io = MagicMock(placeholder="draft") + coder = SimpleNamespace( + tui=None, + voice_language=None, + voice_format=None, + voice_input_device=None, + get_user_language=MagicMock(return_value="English"), + ) + recorder = MagicMock() + recorder.record_and_transcribe = AsyncMock(return_value="final transcript") + return io, coder, recorder + + +@pytest.mark.asyncio +async def test_tui_command_delegates_before_initialization(voice_context): + io, coder, recorder = voice_context + tui = MagicMock() + coder.tui = MagicMock(return_value=tui) + + with patch("cecli.commands.voice.voice.Voice") as voice_class: + result = await VoiceCommand.execute(io, coder, "") + + assert result == "" + tui.call_from_thread.assert_called_once_with(tui.action_start_voice) + tui.action_start_voice.assert_not_called() + voice_class.assert_not_called() + coder.get_user_language.assert_not_called() + io.update_spinner.assert_not_called() + + +@pytest.mark.asyncio +async def test_background_command_uses_voice_binding_and_callbacks(voice_context): + io, coder, recorder = voice_context + tui = MagicMock() + tui.get_keys_for.return_value = "alt+r" + coder.tui = MagicMock(return_value=tui) + stop_queue = queue.Queue() + + async def record(history, **kwargs): + assert history is None + assert kwargs["stop_queue"] is stop_queue + assert kwargs["stop_binding"] == "alt+r" + assert kwargs["language"] == "es" + kwargs["on_text"]("partial transcript") + kwargs["on_status"]("\n⬤ recording: alt+r to stop") + kwargs["on_status"]("\n⬤ Transcribing") + kwargs["on_status"]("\nMicrophone warning\n") + kwargs["on_status"](None) + return "final transcript" + + recorder.record_and_transcribe.side_effect = record + result = await VoiceCommand.execute( + io, coder, "", voice_instance=recorder, stop_queue=stop_queue, voice_language="Spanish" + ) + + assert result == "" + assert io.placeholder == "final transcript" + tui.call_from_thread.assert_not_called() + tui.get_keys_for.assert_called_once_with("voice") + assert tui.set_input_value.call_args_list == [ + call("partial transcript"), + call("final transcript"), + ] + assert tui.refresh.call_count == 2 + assert tui.set_voice_hint.call_args_list == [ + call("⬤ recording"), + call("⬤ recording: alt+r to stop"), + call("⬤ Transcribing"), + ] + io.update_spinner.assert_not_called() + assert io.tool_output.call_args_list == [call("Microphone warning"), call("")] + recorder.close.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("dead_tui_reference", [False, True]) +async def test_cli_preserves_enter_mode_and_partial_placeholder(voice_context, dead_tui_reference): + io, coder, recorder = voice_context + + if dead_tui_reference: + coder.tui = MagicMock(return_value=None) + + async def record(history, **kwargs): + assert history is None + assert kwargs["stop_queue"] is None + assert kwargs["stop_binding"] is None + kwargs["on_text"]("partial") + assert io.placeholder == "partial" + kwargs["on_status"]("\n⬤ recording") + kwargs["on_status"](None) + return "final transcript" + + recorder.record_and_transcribe.side_effect = record + await VoiceCommand.execute(io, coder, "", voice_instance=recorder) + + assert io.placeholder == "final transcript" + io.tool_output.assert_any_call("\n⬤ recording") + io.tool_output.assert_any_call("") + recorder.close.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("owned", [False, True]) +@pytest.mark.parametrize("outcome", ["success", "empty", "error", "cancel"]) +async def test_recorder_ownership_and_cleanup(voice_context, owned, outcome): + io, coder, recorder = voice_context + coder.voice_format = "flac" + coder.voice_input_device = "USB microphone" + coder.get_user_language.return_value = "German" + + if outcome == "error": + recorder.record_and_transcribe.side_effect = RuntimeError("device lost") + elif outcome == "cancel": + recorder.record_and_transcribe.side_effect = asyncio.CancelledError() + elif outcome == "empty": + recorder.record_and_transcribe.return_value = None + + with ( + patch.dict(sys.modules, {"moonshine_voice": MagicMock()}), + patch("cecli.commands.voice.voice.Voice", return_value=recorder) as voice_class, + ): + kwargs = {} if owned else {"voice_instance": recorder} + + if outcome == "cancel": + with pytest.raises(asyncio.CancelledError): + await VoiceCommand.execute(io, coder, "", **kwargs) + else: + await VoiceCommand.execute(io, coder, "", **kwargs) + + if owned: + voice_class.assert_called_once_with(audio_format="flac", device_name="USB microphone") + recorder.close.assert_called_once_with() + else: + voice_class.assert_not_called() + recorder.close.assert_not_called() + + assert recorder.record_and_transcribe.await_args.kwargs["language"] == "de" + + if outcome == "error": + io.tool_error.assert_called_once_with("Unable to transcribe: device lost") + else: + io.tool_error.assert_not_called() + + assert io.placeholder == ("final transcript" if outcome == "success" else "draft") + + +@pytest.mark.asyncio +async def test_missing_moonshine_reports_dependency(voice_context): + io, coder, recorder = voice_context + + with ( + patch.dict(sys.modules, {"moonshine_voice": None}), + patch("cecli.commands.voice.voice.Voice") as voice_class, + ): + await VoiceCommand.execute(io, coder, "") + + voice_class.assert_not_called() + assert "pip install moonshine-voice" in io.tool_error.call_args.args[0] + io.update_spinner.assert_not_called() + + +@pytest.mark.asyncio +async def test_sound_device_initialization_failure_is_reported(voice_context): + io, coder, recorder = voice_context + + with ( + patch.dict(sys.modules, {"moonshine_voice": MagicMock()}), + patch("cecli.commands.voice.voice.Voice", side_effect=SoundDeviceError("no portaudio")), + ): + await VoiceCommand.execute(io, coder, "") + + assert "portaudio" in io.tool_error.call_args.args[0] + io.update_spinner.assert_not_called() + + +def test_help_describes_tui_toggle_and_cli_enter(): + help_text = VoiceCommand.get_help() + assert "Ctrl+R" in help_text + assert "/voice also toggles recording" in help_text + assert "press Enter to stop" in help_text diff --git a/tests/helpers/observations/test_observation_service.py b/tests/helpers/observations/test_observation_service.py index 81ff8fe0e12..a8a438288b8 100644 --- a/tests/helpers/observations/test_observation_service.py +++ b/tests/helpers/observations/test_observation_service.py @@ -68,6 +68,7 @@ async def test_compact_context_with_observations(): coder.last_user_message = "Last user msg" coder.io = MagicMock() coder.args = {} + coder._rate_limit_sleep = AsyncMock() # Mock observation manager with some observations obs_manager = ObservationService.get_instance(coder) @@ -140,6 +141,7 @@ async def test_compact_context_with_observations_integration(): coder.last_user_message = "Last user msg" coder.io = MagicMock() coder.args = {} + coder._rate_limit_sleep = AsyncMock() # Mock observation manager with some observations obs_manager = ObservationService.get_instance(coder) @@ -209,6 +211,7 @@ async def test_run_observation_uses_formatted_chat_chunks(): coder.gpt_prompts = MagicMock() coder.gpt_prompts.observation_prompt = "Observation Prompt" coder.summarizer = MagicMock() + coder._rate_limit_sleep = AsyncMock() def fake_summarize(messages, prompt, max_tokens=None, coder=None): # Mirror ChatSummary.summarize_all_as_text: appends the prompt in place diff --git a/tests/helpers/test_llms_mistral_adapter.py b/tests/helpers/test_llms_mistral_adapter.py new file mode 100644 index 00000000000..baedaa2e02d --- /dev/null +++ b/tests/helpers/test_llms_mistral_adapter.py @@ -0,0 +1,148 @@ +"""Mistral adapter tests: strict message-body sanitization. + +``mistral-flows.har`` shows cecli's replay of an assistant tool-call turn gets +422'd by Mistral, which rejects ``reasoning_content`` / ``provider_specific_fields`` +on assistant messages and ``provider_specific_fields`` plus a null ``index`` on +tool calls. These tests lock in that :class:`MistralProvider.transform_messages` +strips those fields before dispatch. +""" + +import asyncio + +import cecli.helpers.llms.pipeline as pipeline +from cecli.helpers.llms.providers import ProviderAdapter, get_provider_adapter +from cecli.helpers.llms.providers.mistral import MistralProvider +from cecli.helpers.llms.types import Choice, CompletionResponse, Message + + +def _assistant_tool_turn() -> list: + """A replayed assistant tool-call turn shaped like the failing HAR entries.""" + return [ + {"role": "system", "content": "## directives"}, + {"role": "user", "content": "do work"}, + { + "role": "assistant", + "content": "calling tool", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "index": None, + "function": {"name": "local--UpdateTodoList", "arguments": "{}"}, + "provider_specific_fields": {}, + } + ], + "reasoning_content": "internal thought", + "provider_specific_fields": {}, + }, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + ] + + +def test_mistral_provider_is_registered(): + adapter = get_provider_adapter("mistral") + + assert isinstance(adapter, MistralProvider) + assert adapter.provider == "mistral" + + +def test_strips_unsupported_assistant_fields(): + adapter = MistralProvider() + + cleaned = adapter.transform_messages(_assistant_tool_turn()) + + for msg in cleaned: + assert "reasoning_content" not in msg + assert "provider_specific_fields" not in msg + + for tc in cleaned[2]["tool_calls"]: + assert "index" not in tc + assert "provider_specific_fields" not in tc + + +def test_preserves_valid_tool_call_shape(): + adapter = MistralProvider() + + cleaned = adapter.transform_messages(_assistant_tool_turn()) + + tool_call = cleaned[2]["tool_calls"][0] + assert tool_call == { + "id": "call_1", + "type": "function", + "function": {"name": "local--UpdateTodoList", "arguments": "{}"}, + } + + +def test_clean_messages_are_untouched(): + adapter = MistralProvider() + messages = [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "hi", + "tool_calls": [ + {"id": "t", "type": "function", "function": {"name": "x", "arguments": "{}"}} + ], + }, + ] + + assert adapter.transform_messages(messages) is messages + + +def test_does_not_mutate_input(): + adapter = MistralProvider() + messages = _assistant_tool_turn() + + adapter.transform_messages(messages) + + assert messages[2]["tool_calls"][0]["index"] is None + assert "reasoning_content" in messages[2] + assert "provider_specific_fields" in messages[2] + + +def test_base_adapter_is_noop_by_default(): + messages = [ + { + "role": "assistant", + "content": "hi", + "reasoning_content": "x", + "provider_specific_fields": {}, + } + ] + + assert ProviderAdapter().transform_messages(messages) is messages + + +def _fake_response(model): + return CompletionResponse( + id="x", + model=model, + choices=[Choice(index=0, message=Message(role="assistant", content="ok"))], + ) + + +def test_pipeline_sanitizes_messages_for_mistral(monkeypatch): + """acompletion applies the mistral adapter's sanitization end-to-end.""" + captured = {} + + async def fake_chat_complete(resolved, messages, tools, key, headers, kwargs): + captured["messages"] = messages + return _fake_response(resolved["model"]) + + monkeypatch.setattr(pipeline, "chat_complete", fake_chat_complete) + + asyncio.run( + pipeline.acompletion( + model="mistral/ministral-8b-2410", + messages=_assistant_tool_turn(), + ) + ) + + for msg in captured["messages"]: + assert "reasoning_content" not in msg + assert "provider_specific_fields" not in msg + + for tc in captured["messages"][2]["tool_calls"]: + assert "index" not in tc + assert "provider_specific_fields" not in tc diff --git a/tests/helpers/test_llms_reasoning_config.py b/tests/helpers/test_llms_reasoning_config.py index 2b19fdd352f..4176ad88d76 100644 --- a/tests/helpers/test_llms_reasoning_config.py +++ b/tests/helpers/test_llms_reasoning_config.py @@ -138,15 +138,18 @@ def test_anthropic_5_effort_to_output_config(): resolved = resolve_model_config("claude-sonnet-5") payload = _build("anthropic", resolved, {"reasoning_effort": "high"}) assert payload["output_config"] == {"effort": "high"} + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} def test_anthropic_5_thinking_block_dropped(): - """Claude 5+ cannot use thinking.type.enabled; the block must not be sent.""" + """Claude 5+ cannot use ``thinking.type.enabled``; it is replaced with adaptive + thinking that exposes the summarized display.""" resolved = resolve_model_config("claude-sonnet-5") payload = _build( "anthropic", resolved, {"thinking": {"type": "enabled", "budget_tokens": 4096}} ) - assert "thinking" not in payload + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} + assert payload["output_config"] == {"effort": "medium"} assert "reasoning_effort" not in payload @@ -155,7 +158,11 @@ def test_anthropic_pre5_thinking_budget(): payload = _build( "anthropic", resolved, {"thinking": {"type": "enabled", "budget_tokens": 4096}} ) - assert payload["thinking"] == {"type": "enabled", "budget_tokens": 4096} + assert payload["thinking"] == { + "type": "enabled", + "budget_tokens": 4096, + "display": "summarized", + } def test_anthropic_pre5_effort_ignored(): @@ -187,6 +194,16 @@ def test_responses_generic_reasoning_keys_stripped(): assert "thinking" not in payload +def test_responses_copilot_opt_in_to_summary(): + """Copilot gpt-5 (responses mode) opts in to a reasoning summary even with no + configured effort, so the model returns (and the capture logic exposes) the + readable ``summary`` block.""" + resolved = resolve_model_config("github_copilot/gpt-5.1") + payload = _build("responses", resolved, {}) + assert payload["reasoning"] == {"summary": "auto"} + assert payload["include"] == ["reasoning.encrypted_content"] + + # --------------------------------------------------------------------------- # Shim forwarding of top-level reasoning kwargs # --------------------------------------------------------------------------- @@ -252,21 +269,24 @@ def test_model_settings_reach_wire(monkeypatch): "anthropic", "set_reasoning_effort", "low", - {"output_config": {"effort": "low"}}, + { + "output_config": {"effort": "low"}, + "thinking": {"type": "adaptive", "display": "summarized"}, + }, ), ( "anthropic/claude-haiku-4-5-20251001", "anthropic", "set_thinking_tokens", "4k", - {"thinking": {"type": "enabled", "budget_tokens": 4096}}, + {"thinking": {"type": "enabled", "budget_tokens": 4096, "display": "summarized"}}, ), ( "meta/muse-spark-1.2-contributor", "responses", "set_reasoning_effort", "low", - {"reasoning": {"effort": "low"}}, + {"reasoning": {"effort": "low", "summary": "auto"}}, ), ] diff --git a/tests/helpers/test_llms_reasoning_summaries.py b/tests/helpers/test_llms_reasoning_summaries.py new file mode 100644 index 00000000000..ac1ea067dd3 --- /dev/null +++ b/tests/helpers/test_llms_reasoning_summaries.py @@ -0,0 +1,141 @@ +"""Reasoning/thinking summary tracking and replay-stripping for the llms package. + +When a model is configured to expose summaries (``display: "summarized"`` on the +messages API, ``reasoning.summary`` on the responses API), the response captures +the readable summary. We track that in ``provider_specific_fields`` and then +actively strip the summary text when re-injecting (replaying) the conversation +into the next request, keeping only the continuity artifacts (thinking +``signature`` / reasoning ``encrypted_content``). This keeps the cached prompt +prefix stable across turns instead of churning it with per-turn reasoning text. +""" + +from __future__ import annotations + +from cecli.helpers.llms.domains.messages import ( + anthropic_message, + normalize_anthropic_response, +) +from cecli.helpers.llms.domains.responses import ( + normalize_responses_response, + to_responses_input, +) + + +# --- Anthropic (messages) --- +def test_anthropic_summary_flags_and_strips_on_replay(): + data = { + "model": "claude-sonnet-5", + "content": [ + {"type": "thinking", "thinking": "Here is the summary.", "signature": "sig1"}, + {"type": "text", "text": "Answer"}, + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + msg = normalize_anthropic_response(data, "claude-sonnet-5").choices[0].message + + assert msg.provider_specific_fields["use_thinking_summaries"] is True + + replayed = anthropic_message( + { + "role": "assistant", + "content": msg.content, + "provider_specific_fields": msg.provider_specific_fields, + } + ) + thinking = [b for b in replayed["content"] if b["type"] == "thinking"][0] + assert thinking["thinking"] == "" # summary text stripped + assert thinking["signature"] == "sig1" # continuity artifact preserved + + +def test_anthropic_omitted_no_flag_and_replays_as_is(): + data = { + "model": "claude-sonnet-5", + "content": [ + {"type": "thinking", "thinking": "", "signature": "sig0"}, + {"type": "text", "text": "Answer"}, + ], + "stop_reason": "end_turn", + "usage": {}, + } + msg = normalize_anthropic_response(data, "claude-sonnet-5").choices[0].message + + assert msg.provider_specific_fields.get("use_thinking_summaries", False) is False + + replayed = anthropic_message( + { + "role": "assistant", + "content": msg.content, + "provider_specific_fields": msg.provider_specific_fields, + } + ) + thinking = [b for b in replayed["content"] if b["type"] == "thinking"][0] + assert thinking["thinking"] == "" + assert thinking["signature"] == "sig0" + + +def test_anthropic_legacy_no_flag_preserves_summary(): + # A message stashed before the flag existed keeps its summary text on replay. + psf = {"anthropic": [{"type": "thinking", "thinking": "OLD TEXT", "signature": "sig2"}]} + replayed = anthropic_message( + {"role": "assistant", "content": "x", "provider_specific_fields": psf} + ) + thinking = [b for b in replayed["content"] if b["type"] == "thinking"][0] + assert thinking["thinking"] == "OLD TEXT" + + +# --- Responses --- +def test_responses_summary_flags_and_strips_on_replay(): + data = { + "id": "r1", + "model": "gpt-5.1", + "status": "completed", + "output": [ + { + "type": "reasoning", + "id": "rsn1", + "summary": [{"type": "summary_text", "text": "Step-by-step."}], + "encrypted_content": "cipher", + }, + {"type": "message", "content": [{"type": "output_text", "text": "Answer"}]}, + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + msg = normalize_responses_response(data, "gpt-5.1").choices[0].message + + assert msg.provider_specific_fields["use_reasoning_summaries"] is True + + items = to_responses_input( + [ + {"role": "user", "content": "next"}, + { + "role": "assistant", + "content": msg.content, + "provider_specific_fields": msg.provider_specific_fields, + }, + ], + "gpt-5.1", + ) + reasoning = [i for i in items if i.get("type") == "reasoning"][0] + assert reasoning["summary"] == [] # summary stripped + assert reasoning["encrypted_content"] == "cipher" # continuity preserved + assert reasoning["id"] == "rsn1" + + +def test_responses_legacy_no_flag_preserves_summary(): + psf = { + "reasoning_items": [ + { + "type": "reasoning", + "id": "i", + "encrypted_content": "c", + "summary": [{"type": "summary_text", "text": "LEGACY"}], + } + ], + "reasoning_items_origin": "gpt-5.1", + } + items = to_responses_input( + [{"role": "assistant", "content": "x", "provider_specific_fields": psf}], "gpt-5.1" + ) + reasoning = [i for i in items if i.get("type") == "reasoning"][0] + assert reasoning["summary"] == [{"type": "summary_text", "text": "LEGACY"}] diff --git a/tests/helpers/test_onboarding.py b/tests/helpers/test_onboarding.py new file mode 100644 index 00000000000..f409dcb34f3 --- /dev/null +++ b/tests/helpers/test_onboarding.py @@ -0,0 +1,242 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.helpers.onboarding import run_onboarding +from cecli.helpers.onboarding.app import OnboardingApp +from cecli.helpers.onboarding.providers import ( + get_models_for_provider, + iter_providers, + provider_needs_key, +) + + +def _clear_api_keys() -> None: + for key in [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GEMINI_API_KEY", + ]: + os.environ.pop(key, None) + + +def test_iter_providers_includes_builtin_and_providers_json(): + providers = iter_providers() + slugs = {provider["slug"] for provider in providers} + + # Builtin / default providers from llms config + assert "openai" in slugs + assert "anthropic" in slugs + assert "github_copilot" in slugs + assert "deepseek" in slugs + assert "openrouter" in slugs + assert "gemini" in slugs + assert "meta" in slugs + # A handful from providers.json + assert "groq" in slugs + assert "together_ai" in slugs + assert len(slugs) == len(providers) + + +@pytest.mark.parametrize("slug", ["github_copilot", "bedrock", "bedrock_mantle"]) +def test_no_key_providers_skip_key_prompt(slug): + provider = next(provider for provider in iter_providers() if provider["slug"] == slug) + + assert provider_needs_key(provider) is False + + +def test_openai_provider_needs_key(): + provider = next(provider for provider in iter_providers() if provider["slug"] == "openai") + + assert provider_needs_key(provider) is True + + +def test_get_models_for_provider_openai(): + models = get_models_for_provider("openai") + + assert models + assert "openai/gpt-4o" in models + + +def test_get_models_for_provider_anthropic_prefixed(): + models = get_models_for_provider("anthropic") + + assert models + assert all(model.startswith("anthropic/") for model in models) + + +def test_persist_api_keys_and_default_model(monkeypatch, tmp_path): + from cecli.helpers import onboarding + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + config_dir = tmp_path / ".cecli" + config_dir.mkdir() + (config_dir / ".env").write_text("EXISTING=1\n", encoding="utf-8") + (config_dir / "conf.yml").write_text("dark-mode: true\n", encoding="utf-8") + + onboarding._persist_api_keys({"OPENAI_API_KEY": "sk-123"}) + onboarding._persist_default_model("gpt-4o") + + env_text = (config_dir / ".env").read_text(encoding="utf-8") + assert "EXISTING=1" in env_text + assert "OPENAI_API_KEY" in env_text + + conf_text = (config_dir / "conf.yml").read_text(encoding="utf-8") + assert "dark-mode: true" in conf_text + assert "model: gpt-4o" in conf_text + assert "agent: true" in conf_text + + +@pytest.mark.asyncio +async def test_onboarding_app_prompts_for_key_and_returns_result(): + _clear_api_keys() + app = OnboardingApp(iter_providers()) + + async with app.run_test() as pilot: + await pilot.pause(0.2) + + for char in "anthropic": + await pilot.press(char) + await pilot.pause(0.05) + await pilot.press("tab") + await pilot.pause(0.05) + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.provider and app.provider["slug"] == "anthropic" + assert type(app.screen).__name__ == "ApiKeyScreen" + + for char in "sk-x": + await pilot.press(char) + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.api_keys == {"ANTHROPIC_API_KEY": "sk-x"} + + for _ in range(80): + await pilot.pause(0.1) + if type(app.screen).__name__ == "ModelScreen": + break + assert type(app.screen).__name__ == "ModelScreen" + + await pilot.press("tab") + await pilot.pause(0.05) + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.result + assert app.result["model"].startswith("anthropic/") + assert app.result["api_keys"] == {"ANTHROPIC_API_KEY": "sk-x"} + + +@pytest.mark.asyncio +async def test_onboarding_app_skips_key_for_no_key_provider(): + _clear_api_keys() + app = OnboardingApp(iter_providers()) + + async with app.run_test() as pilot: + await pilot.pause(0.2) + + for char in "bedrock": + await pilot.press(char) + await pilot.pause(0.05) + await pilot.press("tab") + await pilot.pause(0.05) + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.provider and app.provider["slug"] == "bedrock" + + for _ in range(80): + await pilot.pause(0.1) + if type(app.screen).__name__ in ("ModelScreen", "ModelManualScreen"): + break + assert type(app.screen).__name__ in ("ModelScreen", "ModelManualScreen") + assert app.api_keys == {} + + +@pytest.mark.asyncio +async def test_filterable_list_arrow_keys_move_highlight_when_input_focused(): + _clear_api_keys() + app = OnboardingApp(iter_providers()) + + async with app.run_test() as pilot: + await pilot.pause(0.3) + screen = app.screen + source = screen.query_one("#providers") + option_list = source.query_one("#options") + search = source.query_one("#filter") + + # Focus sits on the search input, not the items box. + assert screen.focused is search + assert option_list.highlighted == 0 + + await pilot.press("down") + await pilot.pause(0.05) + assert option_list.highlighted == 1 + + await pilot.press("down") + await pilot.pause(0.05) + assert option_list.highlighted == 2 + + await pilot.press("up") + await pilot.pause(0.05) + assert option_list.highlighted == 1 + + +@pytest.mark.asyncio +async def test_filterable_list_enter_submits_highlighted_when_input_focused(): + _clear_api_keys() + app = OnboardingApp(iter_providers()) + + async with app.run_test() as pilot: + await pilot.pause(0.3) + fl = app.screen.query_one("#providers") + fl.query_one("#options").highlighted = 1 + + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.provider and app.provider["slug"] == "anthropic" + + +@pytest.mark.asyncio +async def test_escape_on_api_key_screen_returns_to_provider(): + _clear_api_keys() + app = OnboardingApp(iter_providers()) + + async with app.run_test() as pilot: + await pilot.pause(0.3) + + for char in "anthropic": + await pilot.press(char) + await pilot.pause(0.02) + await pilot.press("tab") + await pilot.pause(0.05) + await pilot.press("enter") + await pilot.pause(0.2) + + assert type(app.screen).__name__ == "ApiKeyScreen" + + await pilot.press("escape") + await pilot.pause(0.3) + + assert type(app.screen).__name__ == "ProviderScreen" + assert app.provider is None + assert app.result is None + + +@pytest.mark.asyncio +async def test_run_onboarding_skips_when_not_tty(): + io = MagicMock() + io.tool_error = MagicMock() + io.tool_output = MagicMock() + + with patch("sys.stdin.isatty", return_value=False): + result = await run_onboarding(io) + + assert result is None diff --git a/tests/helpers/test_skills_importer.py b/tests/helpers/test_skills_importer.py new file mode 100644 index 00000000000..b404b948079 --- /dev/null +++ b/tests/helpers/test_skills_importer.py @@ -0,0 +1,186 @@ +"""Tests for cecli/helpers/extensions/skills_importer.py.""" + +import json +import ssl + +import pytest +import requests +from requests.exceptions import SSLError as RequestsSSLError + +from cecli.helpers.extensions import skills_importer + +imp = skills_importer + + +class FakeResponse: + """Minimal stand-in for a requests.Response returned by a successful GET.""" + + def __init__(self): + self.content = b"fake" + self.status_code = 200 + + +@pytest.mark.parametrize("exc", [ssl.SSLError, RequestsSSLError]) +def test_ssl_safe_get_retries_once_on_ssl_flake(monkeypatch, exc): + """The OpenSSL CONF module lazy-init flake must be retried once.""" + calls = {"n": 0} + sentinel = FakeResponse() + + def flaky_get(url, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise exc("unknown error (0x0) (_ssl.c:3187)") + return sentinel + + monkeypatch.setattr(skills_importer.requests, "get", flaky_get) + + result = skills_importer._ssl_safe_get("https://example.test/fake") + + assert result is sentinel + assert calls["n"] == 2 + + +@pytest.mark.parametrize("exc", [ssl.SSLError, RequestsSSLError]) +def test_ssl_safe_get_propagates_persistent_ssl_error(monkeypatch, exc): + """A persistent SSL flake must propagate after the single retry.""" + calls = {"n": 0} + + def always_fail(url, **kwargs): + calls["n"] += 1 + raise exc("unknown error (0x0) (_ssl.c:3187)") + + monkeypatch.setattr(skills_importer.requests, "get", always_fail) + + with pytest.raises(exc): + skills_importer._ssl_safe_get("https://example.test/fake") + + assert calls["n"] == 2 + + +def _response(status_code=200, payload=None): + """Build a minimal requests.Response carrying a JSON payload.""" + resp = requests.Response() + resp.status_code = status_code + if payload is not None: + resp._content = json.dumps(payload).encode() + return resp + + +def _audit(provider, slug, status="pass", **extra): + """Build an audit entry like the skills.sh audit endpoint returns.""" + entry = {"provider": provider, "slug": slug, "status": status} + entry.update(extra) + return entry + + +class TestFetchSkillAudits: + def test_returns_payload_on_200(self, monkeypatch): + payload = {"id": "a/b/c", "audits": [_audit("Socket", "socket")]} + monkeypatch.setattr(imp, "_ssl_safe_get", lambda *a, **k: _response(200, payload)) + assert imp.fetch_skill_audits("a/b/c") == payload + + def test_none_on_404(self, monkeypatch): + monkeypatch.setattr( + imp, "_ssl_safe_get", lambda *a, **k: _response(404, {"error": "not_found"}) + ) + assert imp.fetch_skill_audits("a/b/c") is None + + def test_none_on_network_error(self, monkeypatch): + def boom(*a, **k): + raise requests.exceptions.ConnectionError("boom") + + monkeypatch.setattr(imp, "_ssl_safe_get", boom) + assert imp.fetch_skill_audits("a/b/c") is None + + def test_none_on_bad_body(self, monkeypatch): + monkeypatch.setattr(imp, "_ssl_safe_get", lambda *a, **k: _response(200, {"nope": True})) + assert imp.fetch_skill_audits("a/b/c") is None + + +class TestSkillPassesSecurityAudits: + def test_all_pass(self, monkeypatch): + payload = { + "audits": [ + _audit("Gen Agent Trust Hub", "agent-trust-hub"), + _audit("Socket", "socket"), + _audit("Snyk", "snyk"), + ] + } + monkeypatch.setattr(imp, "fetch_skill_audits", lambda p: payload) + ok, msg = imp.skill_passes_security_audits("a/b/c") + assert ok is True + assert "pass" in msg.lower() + + def test_missing_required_audit(self, monkeypatch): + payload = {"audits": [_audit("Socket", "socket")]} + monkeypatch.setattr(imp, "fetch_skill_audits", lambda p: payload) + ok, msg = imp.skill_passes_security_audits("a/b/c") + assert ok is False + assert "missing required security audit" in msg.lower() + + def test_non_pass_status(self, monkeypatch): + payload = { + "audits": [ + _audit("Gen Agent Trust Hub", "agent-trust-hub"), + _audit("Socket", "socket"), + _audit("Snyk", "snyk", status="warn"), + ] + } + monkeypatch.setattr(imp, "fetch_skill_audits", lambda p: payload) + ok, msg = imp.skill_passes_security_audits("a/b/c") + assert ok is False + assert "did not pass" in msg + + def test_extra_non_pass_audit_fails(self, monkeypatch): + payload = { + "audits": [ + _audit("Gen Agent Trust Hub", "agent-trust-hub"), + _audit("Socket", "socket"), + _audit("Snyk", "snyk"), + _audit("Runlayer", "runlayer", status="warn"), + ] + } + monkeypatch.setattr(imp, "fetch_skill_audits", lambda p: payload) + ok, _ = imp.skill_passes_security_audits("a/b/c") + assert ok is False + + def test_no_audits_found(self, monkeypatch): + monkeypatch.setattr(imp, "fetch_skill_audits", lambda p: None) + ok, msg = imp.skill_passes_security_audits("a/b/c") + assert ok is False + assert "no security audit results" in msg.lower() + + +class TestInstallSkillAuditGate: + def test_skills_sh_audit_fail_blocks_download(self, monkeypatch): + src = imp.SkillSource( + repo="anthropics/skills", + skill_id="frontend-design", + name="frontend-design", + source="skills.sh", + ) + monkeypatch.setattr(imp, "resolve_skill", lambda name, force=False: src) + monkeypatch.setattr(imp, "skill_passes_security_audits", lambda p: (False, "did not pass")) + res = imp.install_skill("frontend-design") + assert res["ok"] is False + assert "did not pass" in res["message"] + + def test_registry_skill_skips_gate(self, monkeypatch, tmp_path): + src = imp.SkillSource( + repo="cecli-dev/community-resources", + skill_id="files/docx", + name="docx", + source="registry", + ) + monkeypatch.setattr(imp, "resolve_skill", lambda name, force=False: src) + audit_called = {"v": False} + + def fake_audit(spath): + audit_called["v"] = True + return (False, "no") + + monkeypatch.setattr(imp, "skill_passes_security_audits", fake_audit) + monkeypatch.setattr(imp, "download_skill_folder", lambda *a, **k: None) + res = imp.install_skill("docx", root=str(tmp_path)) + assert res["ok"] is True + assert audit_called["v"] is False diff --git a/tests/mcp/test_keepalive_resilience.py b/tests/mcp/test_keepalive_resilience.py index 0ea16931e0f..4f6df9d3038 100644 --- a/tests/mcp/test_keepalive_resilience.py +++ b/tests/mcp/test_keepalive_resilience.py @@ -23,7 +23,11 @@ async def test_temporary_disconnection_recovery(self, http_based_server, running # Simulate temporary disconnection running_mock_server.trigger_disconnect() - await asyncio.sleep(1.2) # Wait for failed ping + + # Poll until the keepalive loop's first failed ping marks the server + # UNHEALTHY. Polling (rather than a fixed sleep) keeps the test robust + # to scheduling/latency differences across CI runners. + await self._wait_for_state(inspector, server, ConnectionState.UNHEALTHY) # Should be UNHEALTHY after first failure assert inspector.get_state(server) == ConnectionState.UNHEALTHY @@ -32,7 +36,9 @@ async def test_temporary_disconnection_recovery(self, http_based_server, running # Restore server running_mock_server.reset() running_mock_server.set_status(200) - await asyncio.sleep(1.2) # Wait for successful ping + + # Poll until a successful keepalive ping restores CONNECTED + await self._wait_for_state(inspector, server, ConnectionState.CONNECTED) # Should recover to CONNECTED assert inspector.get_state(server) == ConnectionState.CONNECTED @@ -40,6 +46,24 @@ async def test_temporary_disconnection_recovery(self, http_based_server, running await server.disconnect() + @staticmethod + async def _wait_for_state(inspector, server, expected, timeout=5.0): + """Poll until the server reaches the expected connection state. + + Avoids relying on a fixed sleep that can race with the keepalive + loop's ping cadence on slower CI runners. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if inspector.get_state(server) == expected: + return + await asyncio.sleep(0.05) + raise AssertionError( + f"Timed out after {timeout}s waiting for state {expected}; " + f"current state is {inspector.get_state(server)}" + ) + @pytest.mark.asyncio async def test_slow_responses_handled_gracefully(self, http_based_server, running_mock_server): """Verify keepalive continues to function with slow server responses.""" diff --git a/tests/tools/test_command_timeout_paging.py b/tests/tools/test_command_timeout_paging.py new file mode 100644 index 00000000000..d2cc2f8d37c --- /dev/null +++ b/tests/tools/test_command_timeout_paging.py @@ -0,0 +1,192 @@ +"""Regression coverage for output paging when a command's timeout actually elapses.""" + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import pytest_asyncio + +from cecli.helpers import background_commands +from cecli.tools.command import Tool as CommandTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool + + +@pytest_asyncio.fixture +async def elapsed_command(monkeypatch, tmp_path): + """Keep process completion pending without starting a real process or worker thread.""" + coder = SimpleNamespace( + root=str(tmp_path), + io=Mock(_last_type=None), + pretty=False, + verbose=False, + tui=None, + mcp_manager=None, + agent_config={}, + abs_fnames={str(tmp_path / "existing.py")}, + abs_read_only_fnames={str(tmp_path / "reference.txt")}, + abs_root_path=Mock(side_effect=lambda path: str(tmp_path / path)), + local_agent_folder=Mock(side_effect=lambda path: f".cecli/agents/test-agent/{path}"), + _add_file_to_context=Mock(), + context_blocks_cache={}, + edit_allowed=False, + interrupt_event=asyncio.Event(), + ) + manager = background_commands.BackgroundCommandManager + target = "bg_1_1234" + process = Mock() + popen = Mock(return_value=process) + buffer = background_commands.CircularBuffer() + get_all = Mock(wraps=buffer.get_all) + monkeypatch.setattr(buffer, "get_all", get_all) + monkeypatch.setattr(background_commands, "CircularBuffer", Mock(return_value=buffer)) + monkeypatch.setattr("subprocess.Popen", popen) + start = Mock(return_value=target) + stop = Mock() + save = Mock(wraps=manager.save_paginated_output) + monkeypatch.setattr(manager, "start_background_command", start) + monkeypatch.setattr(manager, "stop_background_command", stop) + monkeypatch.setattr(manager, "save_paginated_output", save) + pending_tasks = [] + + async def pending_wait(*args, **kwargs): + pending_tasks.append(asyncio.current_task()) + await asyncio.get_running_loop().create_future() + + to_thread = Mock(side_effect=pending_wait) + monkeypatch.setattr(asyncio, "to_thread", to_thread) + + async def execute(output, threshold=8, enabled=True): + coder.large_file_token_threshold = threshold + coder.context_management_enabled = enabled + buffer.append(output) + response = await CommandTool._execute_with_timeout( + coder, "pending command", 0.001, use_pty=False + ) + result = response.to_dict() + assert result["errors"] == [] + assert len(result["result"]) == 1 + content = result["result"][0]["content"] + assert "Command exceeded 0.001s timeout and is continuing in background." in content + assert f"Command key: {target}" in content + assert "completed within" not in content + assert len(pending_tasks) == 1 + assert not pending_tasks[0].done() + assert not coder.interrupt_event.is_set() + to_thread.assert_called_once_with(process.wait) + popen.assert_called_once() + start.assert_called_once() + assert start.call_args.kwargs["existing_process"] is process + assert start.call_args.kwargs["existing_buffer"] is buffer + assert start.call_args.kwargs["persist"] is True + get_all.assert_called_once_with(clear=False) + assert buffer.get_all() == output + stop.assert_not_called() + process.wait.assert_not_called() + process.terminate.assert_not_called() + process.kill.assert_not_called() + return content + + try: + yield SimpleNamespace(execute=execute, coder=coder, target=target, save=save) + finally: + for task in pending_tasks: + task.cancel() + + await asyncio.gather(*pending_tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_elapsed_timeout_saves_pages_readable_without_adding_context(elapsed_command): + output = "first line: café\nsecond line\n" * 3 + content = await elapsed_command.execute(output) + coder = elapsed_command.coder + target = elapsed_command.target + page_size = int(coder.large_file_token_threshold * 3.5) + expected_pages = [ + output[index : index + page_size] for index in range(0, len(output), page_size) + ] + + assert output not in content + assert f"Large Response ({len(output)} characters)" in content + assert f"Output saved in {len(expected_pages)} pages." in content + assert "ResourceManager" in content + assert "not added to file context" in content + assert "command_key::" not in content + example = next(line for line in content.splitlines() if line.startswith('{"paging"')) + assert json.loads(example) == {"paging": [{"target": target, "page": 1}]} + elapsed_command.save.assert_called_once_with( + output=output, + command_key=target, + page_size=page_size, + abs_root_path_func=coder.abs_root_path, + local_agent_folder_func=coder.local_agent_folder, + ) + folder = Path(coder.abs_root_path(coder.local_agent_folder(target))) + assert {path.name for path in folder.iterdir()} == { + f"{page}.txt" for page in range(1, len(expected_pages) + 1) + } + saved_pages = [ + (folder / f"{page}.txt").read_text(encoding="utf-8") + for page in range(1, len(expected_pages) + 1) + ] + assert saved_pages == expected_pages + assert "".join(saved_pages) == output + + editable_before = coder.abs_fnames.copy() + read_only_before = coder.abs_read_only_fnames.copy() + for index in range(0, len(expected_pages), 3): + batch = expected_pages[index : index + 3] + response = await ResourceManagerTool.execute( + coder, + paging=[ + {"target": target, "page": page} + for page in range(index + 1, index + len(batch) + 1) + ], + ) + result = response.to_dict() + assert result["errors"] == [] + assert len(result["result"]) == len(batch) + for item, expected in zip(result["result"], batch): + assert item["content"].endswith(expected) + + assert coder.abs_fnames == editable_before + assert coder.abs_read_only_fnames == read_only_before + coder._add_file_to_context.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "threshold,length,paged", [(8, 35, False), (8, 36, True), (9, 38, False), (9, 39, True)] +) +async def test_elapsed_timeout_paging_uses_strict_rounded_threshold( + elapsed_command, threshold, length, paged +): + output = "x" * length + content = await elapsed_command.execute(output, threshold=threshold) + + if paged: + elapsed_command.save.assert_called_once() + assert elapsed_command.save.call_args.kwargs["page_size"] == int(threshold * 3.5) + assert "Large Response" in content + assert output not in content + else: + elapsed_command.save.assert_not_called() + assert f"Output captured so far:\n{output}\n" in content + assert "Large Response" not in content + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output,enabled", [("", True), ("small output\n", True), ("large output\n" * 100, False)] +) +async def test_elapsed_timeout_keeps_small_empty_or_unmanaged_output_inline( + elapsed_command, output, enabled +): + content = await elapsed_command.execute(output, enabled=enabled) + + assert f"Output captured so far:\n{output}\n" in content + assert "Large Response" not in content + elapsed_command.save.assert_not_called() diff --git a/tests/tools/test_resource_manager_paging.py b/tests/tools/test_resource_manager_paging.py new file mode 100644 index 00000000000..04ecc1576b9 --- /dev/null +++ b/tests/tools/test_resource_manager_paging.py @@ -0,0 +1,315 @@ +"""ResourceManager paging returns command output without adding context files.""" + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.utils.helpers import ToolError +from cecli.tools.utils.responses import ToolResponse + + +@pytest.fixture +def coder(tmp_path): + return SimpleNamespace( + io=Mock(_last_type=None), + pretty=False, + verbose=False, + tui=None, + mcp_manager=None, + agent_config={}, + abs_fnames={str(tmp_path / "existing.py")}, + abs_read_only_fnames={str(tmp_path / "reference.txt")}, + abs_root_path=Mock(side_effect=lambda path: str(tmp_path / path)), + local_agent_folder=Mock(side_effect=lambda path: f".cecli/agents/test-agent/{path}"), + _add_file_to_context=Mock(), + context_blocks_cache={}, + edit_allowed=False, + ) + + +@pytest.fixture +def operations(monkeypatch): + """Record all context operations without causing filesystem or service side effects.""" + mocks = {} + for name in ( + "_create", + "_remove", + "_view", + "_editable", + "_stop_command", + "_load_skill", + "_remove_skill", + "_load_mcp", + "_remove_mcp", + "_list_mcp_servers", + ): + mock_type = AsyncMock if name in ("_load_mcp", "_remove_mcp", "_list_mcp_servers") else Mock + mocks[name] = mock_type(return_value="operation completed") + monkeypatch.setattr(ResourceManagerTool, name, mocks[name]) + + return mocks + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target,page", [("bg_1_1234", 1), ("bg_42_987654", 2)]) +@pytest.mark.parametrize("content", ["first line\nsecond line: café\n", ""]) +async def test_paging_returns_page_contents_without_adding_context(coder, target, page, content): + page_path = Path(coder.abs_root_path(coder.local_agent_folder(f"{target}/{page}.txt"))) + page_path.parent.mkdir(parents=True) + page_path.write_text(content, encoding="utf-8") + editable_before = coder.abs_fnames.copy() + read_only_before = coder.abs_read_only_fnames.copy() + coder.abs_root_path.reset_mock() + coder.local_agent_folder.reset_mock() + + response = await ResourceManagerTool.execute(coder, paging=[{"target": target, "page": page}]) + + assert isinstance(response, ToolResponse) + result = response.to_dict() + assert result["errors"] == [] + assert len(result["result"]) == 1 + assert result["result"][0]["content"].endswith(content) + coder.local_agent_folder.assert_called_with(f"{target}/{page}.txt") + coder.abs_root_path.assert_any_call(f".cecli/agents/test-agent/{target}/{page}.txt") + assert coder.abs_fnames == editable_before + assert coder.abs_read_only_fnames == read_only_before + coder._add_file_to_context.assert_not_called() + + +@pytest.mark.asyncio +async def test_missing_page_appends_response_error(coder): + editable_before = coder.abs_fnames.copy() + read_only_before = coder.abs_read_only_fnames.copy() + + response = await ResourceManagerTool.execute(coder, paging=[{"target": "bg_1_1234", "page": 1}]) + + assert isinstance(response, ToolResponse) + result = response.to_dict() + assert result["result"] == [] + assert result["errors"] + assert coder.abs_fnames == editable_before + assert coder.abs_read_only_fnames == read_only_before + coder._add_file_to_context.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "paging", + [ + [], + {"target": "bg_1_1234", "page": 1}, + "bg_1_1234/1.txt", + 1, + False, + [{}], + [{"target": "bg_1_1234"}], + [{"page": 1}], + [{"target": "bg_1_1234", "page": 1, "extra": True}], + [None], + [[{"target": "bg_1_1234", "page": 1}]], + [{"target": "bg_1_1234", "page": 1}] * 4, + ], +) +async def test_invalid_paging_object_raises_before_operations(coder, operations, paging): + with pytest.raises(ToolError): + await ResourceManagerTool.execute(coder, paging=paging, create=["untouched.txt"]) + + for operation in operations.values(): + operation.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "target", + [ + "", + "bg_1", + "bg_a_1234", + "bg_1_abcd", + "bg_-1_1234", + "BG_1_1234", + "prefix_bg_1_1234", + "bg_1_1234_suffix", + "bg_1_1234\n", + "../bg_1_1234", + "bg_1_1234/../../secret", + 1234, + None, + True, + ], +) +async def test_invalid_paging_target_raises_before_operations(coder, operations, target): + with pytest.raises(ToolError): + await ResourceManagerTool.execute( + coder, paging=[{"target": target, "page": 1}], create=["untouched.txt"] + ) + + for operation in operations.values(): + operation.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("page", [0, -1, 1.0, 1.5, "1", True, False, None, [], {}]) +async def test_invalid_page_number_raises_before_operations(coder, operations, page): + with pytest.raises(ToolError): + await ResourceManagerTool.execute( + coder, paging=[{"target": "bg_1_1234", "page": page}], create=["untouched.txt"] + ) + + for operation in operations.values(): + operation.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["add", "read_only"]) +@pytest.mark.parametrize("alias", ["command_key::bg_1_1234/1.txt", "command_key::bg_1_1234"]) +async def test_command_key_alias_rejected_before_any_operations(coder, operations, action, alias): + kwargs = { + "create": ["untouched.txt"], + "remove": ["existing.py"], + "add": ["aaa.py"], + "read_only": ["aaa-reference.txt"], + "stop": ["bg_2_1234"], + "load_skill": ["test-skill"], + "remove_skill": ["old-skill"], + "load_mcp": ["test-server"], + "remove_mcp": ["old-server"], + "actions": ["list_mcp_servers"], + } + kwargs[action].append(alias) + + with pytest.raises(ToolError): + await ResourceManagerTool.execute(coder, **kwargs) + + for operation in operations.values(): + operation.assert_not_called() + + coder._add_file_to_context.assert_not_called() + + +def test_paging_schema_requires_exact_target_and_positive_integer_page(): + parameters = ResourceManagerTool.SCHEMA["function"]["parameters"] + paging = parameters["properties"]["paging"] + + assert paging["type"] == "array" + paging = paging["items"] + assert paging["type"] == "object" + assert set(paging["required"]) == {"target", "page"} + assert paging["additionalProperties"] is False + assert set(paging["properties"]) == {"target", "page"} + assert paging["properties"]["target"]["type"] == "string" + assert paging["properties"]["page"]["type"] == "integer" + assert paging["properties"]["page"]["minimum"] == 1 + assert "pattern" not in paging["properties"]["target"] + assert "bg_" in paging["properties"]["target"]["description"] + assert "paging" not in parameters.get("required", []) + + +def test_format_output_shows_paging_target_and_page(coder): + tool_response = SimpleNamespace( + id="test-paging", + type="function", + function=SimpleNamespace( + name="ResourceManager", + arguments=json.dumps({"paging": [{"target": "bg_1_1234", "page": 7}]}), + ), + ) + + ResourceManagerTool.format_output(coder, SimpleNamespace(name="Local"), tool_response) + + output = "\n".join( + str(call.args[0]) for call in coder.io.tool_output.call_args_list if call.args + ) + assert "bg_1_1234" in output + assert "7" in output + assert "pag" in output.lower() + coder.io.tool_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_three_pages_return_in_order_without_context_changes(coder): + paging = [{"target": "bg_1_1234", "page": page} for page in (3, 1, 2)] + contents = [f"unique content for page {item['page']}\n" for item in paging] + for item, content in zip(paging, contents): + path = Path(coder.abs_root_path(coder.local_agent_folder(f"bg_1_1234/{item['page']}.txt"))) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + editable_before = coder.abs_fnames.copy() + read_only_before = coder.abs_read_only_fnames.copy() + response = await ResourceManagerTool.execute(coder, paging=paging) + result = response.to_dict() + + assert result["errors"] == [] + assert len(result["result"]) == 3 + for item, content in zip(result["result"], contents): + assert item["content"].endswith(content) + + assert coder.abs_fnames == editable_before + assert coder.abs_read_only_fnames == read_only_before + coder._add_file_to_context.assert_not_called() + + +@pytest.mark.asyncio +async def test_invalid_later_entry_validated_before_reading_or_operations(coder, operations): + paging = [{"target": "bg_1_1234", "page": 1}, {"target": "bg_1_1234", "page": False}] + + with pytest.raises(ToolError): + await ResourceManagerTool.execute(coder, paging=paging, create=["untouched.txt"]) + + coder.local_agent_folder.assert_not_called() + coder.abs_root_path.assert_not_called() + for operation in operations.values(): + operation.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("execution_path", ["foreground", "timeout"]) +async def test_command_large_output_guidance_uses_paging_array( + coder, monkeypatch, tmp_path, execution_path +): + import asyncio + + from cecli.helpers import background_commands + from cecli.tools import command + + coder.root = str(tmp_path) + coder.large_file_token_threshold = 10 + coder.context_management_enabled = True + coder.interrupt_event = asyncio.Event() + manager = command.BackgroundCommandManager + target = "bg_1_1234" + output = "large command output\n" * 50 + save = Mock(return_value=("pages", ["1.txt", "2.txt"], ["command_key::old/1.txt"])) + monkeypatch.setattr(manager, "save_paginated_output", save) + monkeypatch.setattr(manager, "_generate_command_key", Mock(return_value=target)) + + if execution_path == "foreground": + monkeypatch.setattr(command, "run_cmd_subprocess", Mock(return_value=(0, output))) + response = await command.Tool._execute_foreground(coder, "echo test") + else: + process = Mock() + process.wait.return_value = 0 + monkeypatch.setattr("subprocess.Popen", Mock(return_value=process)) + monkeypatch.setattr(manager, "start_background_command", Mock(return_value=target)) + monkeypatch.setattr(manager, "stop_background_command", Mock()) + buffer = Mock() + buffer.get_all.return_value = output + monkeypatch.setattr(background_commands, "CircularBuffer", Mock(return_value=buffer)) + response = await command.Tool._execute_with_timeout(coder, "echo test", 30, use_pty=False) + + result = response.to_dict() + assert result["errors"] == [] + content = result["result"][0]["content"] + example = next(line for line in content.splitlines() if line.startswith('{"paging"')) + assert json.loads(example) == {"paging": [{"target": target, "page": 1}]} + assert "ResourceManager" in content + assert "command_key::" not in content + assert "not added to file context" in content + save.assert_called_once() + assert save.call_args.kwargs["output"] == output + assert save.call_args.kwargs["command_key"] == target diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index a69f3127d63..89ee8259722 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -17,6 +17,8 @@ def tui_instance(monkeypatch): tui._confirmation_lock = False tui._confirmations_pending = [] tui._sub_agent_containers = {} + tui._voice_stop_queue = None + tui._voice_stopping = False return tui @@ -397,3 +399,117 @@ def test_on_input_area_submit_intercepts_workspace(tui_instance): "/workspace ws:app", "/workspace ws:app", ) + + +@pytest.mark.asyncio +async def test_voice_toggle_runs_background_and_stops_only_once(tui_instance): + import queue + + coder = MagicMock(uuid="foreground") + tui_instance._get_visible_coder = MagicMock(return_value=coder) + tui_instance.run_worker = MagicMock() + tui_instance.input_queue = queue.Queue() + tui_instance.show_error = MagicMock() + tui_instance.set_voice_hint = MagicMock() + tui_instance.update_key_hints = MagicMock() + + with ( + patch("cecli.commands.voice.VoiceCommand.execute", new_callable=AsyncMock) as execute, + patch("cecli.tui.app.queues.push_coder_input") as push_input, + patch("cecli.tui.app.queues.wake_input_waiters") as wake_input, + ): + tui_instance.action_start_voice() + stop_queue = tui_instance._voice_stop_queue + coroutine = tui_instance.run_worker.call_args.args[0] + + try: + assert isinstance(stop_queue, queue.Queue) + assert stop_queue.empty() + assert not tui_instance._voice_stopping + tui_instance.run_worker.assert_called_once_with(coroutine, group="voice") + tui_instance.action_start_voice() + tui_instance.action_start_voice() + tui_instance.action_start_voice() + assert tui_instance._voice_stopping + assert stop_queue.get_nowait() is None + assert stop_queue.empty() + assert tui_instance.run_worker.call_count == 1 + tui_instance._get_visible_coder.assert_called_once_with() + assert tui_instance.input_queue.empty() + push_input.assert_not_called() + wake_input.assert_not_called() + finally: + await coroutine + + execute.assert_awaited_once_with(coder.io, coder, "", stop_queue=stop_queue) + assert tui_instance._voice_stop_queue is None + assert not tui_instance._voice_stopping + tui_instance.set_voice_hint.assert_called_once_with("⬤ recording") + tui_instance.update_key_hints.assert_called_once_with( + generating=tui_instance._currently_generating + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "error", "cancel"]) +async def test_run_voice_resets_state_on_every_outcome(tui_instance, outcome): + import asyncio + import queue + + coder = MagicMock(uuid="sub-agent") + stop_queue = queue.Queue() + tui_instance._voice_stop_queue = stop_queue + tui_instance._voice_stopping = True + tui_instance.show_error = MagicMock() + tui_instance.set_voice_hint = MagicMock() + tui_instance.update_key_hints = MagicMock() + error = {"success": None, "error": RuntimeError("failed"), "cancel": asyncio.CancelledError()}[ + outcome + ] + + with patch( + "cecli.commands.voice.VoiceCommand.execute", new=AsyncMock(side_effect=error) + ) as execute: + if outcome == "cancel": + with pytest.raises(asyncio.CancelledError): + await tui_instance._run_voice(coder, stop_queue) + else: + await tui_instance._run_voice(coder, stop_queue) + + execute.assert_awaited_once_with(coder.io, coder, "", stop_queue=stop_queue) + assert tui_instance._voice_stop_queue is None + assert not tui_instance._voice_stopping + tui_instance.update_key_hints.assert_called_once_with( + generating=tui_instance._currently_generating + ) + tui_instance.set_voice_hint.assert_called_once_with("⬤ recording") + + if outcome == "error": + tui_instance.show_error.assert_called_once_with("Unable to record voice: failed") + else: + tui_instance.show_error.assert_not_called() + + +@pytest.mark.parametrize("text", ["/voice", " /voice \n"]) +def test_submit_voice_intercepts_without_agent_queue(tui_instance, text): + import queue + + input_area = MagicMock(value=text) + tui_instance.query_one = MagicMock(return_value=input_area) + tui_instance.action_start_voice = MagicMock() + tui_instance.add_user_message = MagicMock() + tui_instance.input_queue = queue.Queue() + + with ( + patch("cecli.tui.app.queues.push_coder_input") as push_input, + patch("cecli.tui.app.queues.wake_input_waiters") as wake_input, + ): + tui_instance.on_input_area_submit(MagicMock(value=text)) + + assert input_area.value == "" + tui_instance.action_start_voice.assert_called_once_with() + input_area.save_to_history.assert_not_called() + tui_instance.add_user_message.assert_not_called() + assert tui_instance.input_queue.empty() + push_input.assert_not_called() + wake_input.assert_not_called() diff --git a/tests/unit/test_retry_backoff.py b/tests/unit/test_retry_backoff.py index f81b74d1f92..27bafbc6a21 100644 --- a/tests/unit/test_retry_backoff.py +++ b/tests/unit/test_retry_backoff.py @@ -441,7 +441,7 @@ def mock_sleep(delay): assert len(slept_delays) == 1 assert slept_delays[0] == 1.5 - assert result == "generated commit" + assert result[0] == "generated commit" asyncio.run(run_test()) @@ -493,7 +493,7 @@ def mock_sleep(delay): assert len(slept_delays) == 1 assert slept_delays[0] == 2.0 - assert result == "summary output" + assert result[0] == "summary output" asyncio.run(run_test()) @@ -531,6 +531,6 @@ def mock_sleep(delay): ) assert len(slept_delays) == 0 - assert result is None + assert result == (None, None) asyncio.run(run_test())