From 36a4126c2cddfbb06f1ce43c5c59517e63d03f0c Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 17 Aug 2026 17:56:51 -0400 Subject: [PATCH 01/15] Caching is the domain of the LLM module only --- cecli/helpers/conversation/manager.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/cecli/helpers/conversation/manager.py b/cecli/helpers/conversation/manager.py index 8c42a4addcc..3f2740811f7 100644 --- a/cecli/helpers/conversation/manager.py +++ b/cecli/helpers/conversation/manager.py @@ -358,18 +358,6 @@ def get_messages_dict( with safe_open(".cecli/logs/conversation.log", "w") as f: json.dump(messages_dict, f, indent=4, default=lambda o: "") - # Add cache control headers when getting all messages (for LLM consumption) - # Only add cache control if the coder has add_cache_headers = True - if tag is None: - if ( - coder - and hasattr(coder, "add_cache_headers") - and coder.add_cache_headers - and hasattr(coder, "main_model") - and not coder.main_model.caches_by_default - and not getattr(coder.main_model, "uses_messages_api", False) - ): - messages_dict = self._add_cache_control(messages_dict) return messages_dict def clear_tag(self, tag: str, ratio: float = 0) -> None: From 719764cece441d8fbf76096992ebe59b03157839 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 17 Aug 2026 18:06:49 -0400 Subject: [PATCH 02/15] No longer need to downlaod LiteLLM metadata file as a backup --- cecli/models.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index f66744f7936..eb48c352c69 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -142,7 +142,6 @@ class ModelSettings: class ModelInfoManager: - MODEL_INFO_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" CACHE_TTL = 60 * 60 * 24 def __init__(self): @@ -177,28 +176,6 @@ def _load_cache(self): pass self._cache_loaded = True - def _update_cache(self): - try: - import requests - - response = requests.get(self.MODEL_INFO_URL, timeout=5, verify=self.verify_ssl) - if response.status_code == 200: - # Use json.dumps(response.json()) instead of response.text for - # compatibility with mocked responses in tests - parsed = response.json() - self._raw_content = json.dumps(parsed) - try: - parsed = response.json() - self.cache_file.write_text(json.dumps(parsed, indent=4)) - except OSError: - pass - except Exception as ex: - print(str(ex)) - try: - self.cache_file.write_text("{}") - except OSError: - pass - def _get_entry_from_raw(self, key): """Parse a single model entry from raw JSON string without loading the entire dict.""" return get_entry_from_raw(self._raw_content, key) @@ -208,8 +185,6 @@ def get_model_from_cached_json_db(self, model): if data: return data self._load_cache() - if not self._raw_content: - self._update_cache() if not self._raw_content: return dict() info = self._get_entry_from_raw(model) From a5ff62c2279a65bcbec68fffb7f36d5e4ec8571b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 17 Aug 2026 19:32:20 -0400 Subject: [PATCH 03/15] Fix input delta parsing for messages API --- cecli/helpers/llms/domains/messages.py | 76 ++++++++++- tests/helpers/test_llms_messages_stream.py | 142 +++++++++++++++++++ tests/helpers/test_llms_messages_wire.py | 151 +++++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 tests/helpers/test_llms_messages_stream.py create mode 100644 tests/helpers/test_llms_messages_wire.py diff --git a/cecli/helpers/llms/domains/messages.py b/cecli/helpers/llms/domains/messages.py index fabe17b1867..f3077608fa7 100644 --- a/cecli/helpers/llms/domains/messages.py +++ b/cecli/helpers/llms/domains/messages.py @@ -45,9 +45,12 @@ def anthropic_payload( kwargs: Dict[str, Any], ) -> Dict[str, Any]: system = system_prompt(messages) + wire_messages = _coalesce_anthropic_messages( + [anthropic_message(m) for m in messages if m.get("role") != "system"] + ) payload: Dict[str, Any] = { "model": resolved["route"], - "messages": [anthropic_message(m) for m in messages if m.get("role") != "system"], + "messages": wire_messages, "max_tokens": ( kwargs.get("max_tokens") or resolved.get("llm_block", {}).get("max_tokens") or 4096 ), @@ -274,6 +277,7 @@ async def anthropic_stream( "id": block.get("id", ""), "name": block.get("name", ""), "input": block.get("input") or {}, + "_input_raw": "", } elif evt == "content_block_delta": @@ -291,7 +295,21 @@ async def anthropic_stream( elif dtype == "signature_delta": entry["signature"] = delta.get("signature") + elif dtype == "input_json_delta": + entry["_input_raw"] += delta.get("partial_json") or "" + elif evt == "content_block_stop": + entry = blocks.get(current) if current is not None else None + + if entry is not None: + raw = entry.pop("_input_raw", None) + + if raw is not None: + try: + entry["input"] = json.loads(raw) + except json.JSONDecodeError: + pass + current = None chunk = parse_anthropic_chunk(json_obj) @@ -743,6 +761,62 @@ def _is_tool_turn(msg: Dict[str, Any]) -> bool: ) +def _coalesce_anthropic_messages(wire_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Merge consecutive user turns into a single user message. + + The conversation manager emits one user message per tool result and can + inject file-context text as its own user message directly after the + results, so the raw wire list can contain several ``user`` messages in a + row. The Messages API requires alternating roles and, per the tool-use + spec, ``tool_result`` blocks must come first in the user message that + follows an assistant ``tool_use`` turn. Text runs (across messages) are + concatenated with ``"\n---\n"`` separators. + """ + result: List[Dict[str, Any]] = [] + pending: List[Dict[str, Any]] = [] + + def flush() -> None: + if not pending: + return + + tool_results = [b for b in pending if b.get("type") == "tool_result"] + others = [b for b in pending if b.get("type") != "tool_result"] + content: List[Dict[str, Any]] = list(tool_results) + text_parts: List[str] = [] + + for block in others: + if block.get("type") == "text": + text_parts.append(block.get("text") or "") + + continue + + if text_parts: + content.append({"type": "text", "text": "\n---\n".join(text_parts)}) + text_parts = [] + + content.append(block) + + if text_parts: + content.append({"type": "text", "text": "\n---\n".join(text_parts)}) + + result.append({"role": "user", "content": content}) + pending.clear() + + for msg in wire_messages: + if msg.get("role") != "user": + flush() + result.append(msg) + continue + + content = msg.get("content") + blocks = content if isinstance(content, list) else [{"type": "text", "text": content}] + pending.extend(blocks) + + flush() + + return result + + __all__ = [ "anthropic_payload", "anthropic_message", diff --git a/tests/helpers/test_llms_messages_stream.py b/tests/helpers/test_llms_messages_stream.py new file mode 100644 index 00000000000..77606c7b443 --- /dev/null +++ b/tests/helpers/test_llms_messages_stream.py @@ -0,0 +1,142 @@ +"""Streaming stash fidelity for anthropic tool_use input. + +Tool-call arguments arrive over SSE as ``input_json_delta`` partial_json +fragments. ``anthropic_stream`` must accumulate them into the stashed +``tool_use`` block so the next-turn replay (and prompt-cache prefix) keeps +the exact ``input`` the model sent instead of ``{}``. + +No network: the family adapter's ``make_client`` is monkeypatched. +""" + +import asyncio +import json + +from cecli.helpers.llms.config import resolve_model_config +from cecli.helpers.llms.domains import messages as messages_domain +from cecli.helpers.llms.domains.messages import anthropic_payload, anthropic_stream + + +def _sse(obj): + return f"data: {json.dumps(obj)}" + + +class _FakeStreamClient: + """Stand-in for ``make_client`` supporting the stream() context manager.""" + + def __init__(self, lines): + self._lines = lines + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + def stream(self, *args, **kwargs): + return self + + def raise_for_status(self): + pass + + async def aiter_lines(self): + for line in self._lines: + yield line + + +def _run(gen): + async def collect(): + return [c async for c in gen] + + return asyncio.new_event_loop().run_until_complete(collect()) + + +def _stream_tool_use_chunks(monkeypatch, partials): + lines = [ + _sse( + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_01", + "name": "Local--ReadFile", + "input": {}, + }, + } + ), + ] + lines += [ + _sse( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": p}, + } + ) + for p in partials + ] + lines += [ + _sse({"type": "content_block_stop", "index": 0}), + _sse( + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use"}, + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + ), + ] + + client = _FakeStreamClient(lines) + monkeypatch.setattr(messages_domain, "make_client", lambda *a, **k: client) + resolved = resolve_model_config("claude-sonnet-5") + + return _run( + anthropic_stream(resolved, [{"role": "user", "content": "hi"}], None, "key", {}, {}) + ) + + +def _stashed_tool_use(chunks): + stash = next( + c.provider_specific_fields.get("anthropic") + for c in chunks + if c.provider_specific_fields.get("anthropic") + ) + + return next(b for b in stash if b["type"] == "tool_use") + + +def test_streaming_stash_keeps_full_tool_use_input(monkeypatch): + chunks = _stream_tool_use_chunks(monkeypatch, ['{"r', 'ead": [', '{"file_path": "a.py"}]}']) + tool_use = _stashed_tool_use(chunks) + + assert tool_use["id"] == "toolu_01" + assert tool_use["name"] == "Local--ReadFile" + assert tool_use["input"] == {"read": [{"file_path": "a.py"}]} + assert "_input_raw" not in tool_use + + +def test_streamed_input_round_trips_into_next_payload(monkeypatch): + chunks = _stream_tool_use_chunks(monkeypatch, ['{"sear', 'ches": [', '".py"]}']) + stash = next( + c.provider_specific_fields.get("anthropic") + for c in chunks + if c.provider_specific_fields.get("anthropic") + ) + + messages = [ + {"role": "user", "content": "search"}, + {"role": "assistant", "provider_specific_fields": {"anthropic": stash}}, + {"role": "tool", "tool_call_id": "toolu_01", "content": "ok"}, + ] + resolved = resolve_model_config("claude-sonnet-5") + payload = anthropic_payload(resolved, messages, None, False, {}) + + tool_use = next( + b + for m in payload["messages"] + if m["role"] == "assistant" + for b in m["content"] + if b["type"] == "tool_use" + ) + + assert tool_use["input"] == {"searches": [".py"]} diff --git a/tests/helpers/test_llms_messages_wire.py b/tests/helpers/test_llms_messages_wire.py new file mode 100644 index 00000000000..99788ef1d82 --- /dev/null +++ b/tests/helpers/test_llms_messages_wire.py @@ -0,0 +1,151 @@ +"""Wire-format tests for the anthropic messages domain. + +``anthropic_payload`` must produce a spec-compliant /v1/messages request body: + +- roles alternate (no consecutive user messages), with all ``tool_result`` + blocks for a parallel tool call grouped into one user message and any + following text placed after them (tool-use spec ordering), and +- consecutive user text turns are concatenated into a single ``text`` block + joined with ``"\n---\n"`` separators (the conversation manager injects + file-context text as its own user messages, so it rides along inline). + +No network: only the offline payload builder is exercised. +""" + +from cecli.helpers.llms.config import resolve_model_config +from cecli.helpers.llms.domains.messages import anthropic_payload + +SYS = "You are cecli, an agentic coding assistant. " * 30 + +FILE_CONTEXT = ( + "ID-Prefixed Context For:\ncecli/tools/__init__.py\n\n" + '{"file_path": "/home/cecli/cecli/tools/__init__.py", "results": [...]}' +) + +FILE_CONTENT = ( + "Original File Contents For:\ncecli/tools/utils/base_tool.py\n\n" + "from abc import ABC, abstractmethod\n...\n\n" + "Modifications will be communicated as diff messages.\n\n" +) + +# Mirrors the conversation manager's sequence around a parallel tool call: +# several tool results followed by injected file-context text, all as +# consecutive user turns in the internal (OpenAI-style) message list. +TRACE_LIKE = [ + {"role": "system", "content": SYS}, + {"role": "user", "content": "Hello\n"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "Can you explain cecli/tools to me"}, + { + "role": "assistant", + "provider_specific_fields": { + "anthropic": [ + {"type": "thinking", "thinking": "", "signature": "sig1"}, + {"type": "tool_use", "id": "t1", "name": "Local--ls", "input": {}}, + {"type": "tool_use", "id": "t2", "name": "Local--ls", "input": {}}, + {"type": "tool_use", "id": "t3", "name": "Local--ls", "input": {}}, + ] + }, + }, + {"role": "tool", "tool_call_id": "t1", "content": '{"result": [1]}'}, + {"role": "tool", "tool_call_id": "t2", "content": '{"result": [2]}'}, + {"role": "tool", "tool_call_id": "t3", "content": '{"result": [3]}'}, + {"role": "user", "content": FILE_CONTEXT}, + {"role": "user", "content": FILE_CONTENT}, +] + + +def _wire_roles(payload): + return [m["role"] for m in payload["messages"]] + + +def _block_types(msg): + return [b.get("type") for b in msg["content"]] + + +def _assert_alternating(roles): + assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1)) + + +def test_copilot_coalesces_turns_and_keeps_file_text_inline(): + resolved = resolve_model_config("github_copilot/claude-sonnet-5") + payload = anthropic_payload(resolved, TRACE_LIKE, None, False, {}) + + _assert_alternating(_wire_roles(payload)) + + # No input_artifacts: file-context text rides inline in the history. + assert "input_artifacts" not in payload + + # The three parallel tool results are grouped into one user message with + # the trailing file-context text after them, joined with "\n---\n". + tool_msg = next( + m for m in payload["messages"] if any(b.get("type") == "tool_result" for b in m["content"]) + ) + assert [b["tool_use_id"] for b in tool_msg["content"][:3]] == ["t1", "t2", "t3"] + assert _block_types(tool_msg) == ["tool_result"] * 3 + ["text"] + assert tool_msg["content"][3]["text"] == FILE_CONTEXT + "\n---\n" + FILE_CONTENT + + +def test_direct_anthropic_coalesces_turns_and_keeps_file_text_inline(): + resolved = resolve_model_config("claude-sonnet-5") + payload = anthropic_payload(resolved, TRACE_LIKE, None, False, {}) + + _assert_alternating(_wire_roles(payload)) + + # No input_artifacts extension for either provider: file text stays inline. + assert "input_artifacts" not in payload + tool_msg = next( + m for m in payload["messages"] if any(b.get("type") == "tool_result" for b in m["content"]) + ) + types = _block_types(tool_msg) + assert types[:3] == ["tool_result"] * 3 + assert types[3:] == ["text"] + assert tool_msg["content"][3]["text"] == FILE_CONTEXT + "\n---\n" + FILE_CONTENT + + +def test_new_user_question_after_tool_results_merges_text_after_results(): + messages = [ + { + "role": "assistant", + "provider_specific_fields": { + "anthropic": [{"type": "tool_use", "id": "t1", "name": "Local--ls", "input": {}}] + }, + }, + {"role": "tool", "tool_call_id": "t1", "content": "ok"}, + {"role": "user", "content": "what next?"}, + ] + resolved = resolve_model_config("github_copilot/claude-sonnet-5") + payload = anthropic_payload(resolved, messages, None, False, {}) + + user_msg = payload["messages"][-1] + assert _block_types(user_msg) == ["tool_result", "text"] + assert user_msg["content"][0]["tool_use_id"] == "t1" + assert user_msg["content"][1]["text"] == "what next?" + + +def test_consecutive_text_users_merge_into_one(): + messages = [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + resolved = resolve_model_config("github_copilot/claude-sonnet-5") + payload = anthropic_payload(resolved, messages, None, False, {}) + + assert _wire_roles(payload) == ["user"] + assert _block_types(payload["messages"][0]) == ["text"] + assert payload["messages"][0]["content"][0]["text"] == "first\n---\nsecond" + + +def test_file_context_text_merges_with_previous_user_message(): + messages = [ + {"role": "user", "content": "Hello\n"}, + {"role": "user", "content": FILE_CONTEXT}, + ] + resolved = resolve_model_config("github_copilot/claude-sonnet-5") + payload = anthropic_payload(resolved, messages, None, False, {}) + + assert "input_artifacts" not in payload + assert _wire_roles(payload) == ["user"] + assert payload["messages"][0]["content"][0]["text"] == ( + "Hello\n\n---\n" + FILE_CONTEXT + ) From 0671e319e3be3c515b67173d7173b5e32f0ffc42 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 12:47:52 +0200 Subject: [PATCH 04/15] make it possible for sub-agents to use MCP servers --- cecli/coders/agent_coder.py | 4 ++-- cecli/coders/base_coder.py | 8 ++++---- cecli/helpers/agents/service.py | 10 +++++++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 08370f38e46..c58aa5bd1ab 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1627,9 +1627,9 @@ def get_servers_context(self): inactive_servers = [] for server in connected_servers: name = server.name - if incl and name not in incl: + if incl and name.lower() not in incl: inactive_servers.append(name) - elif name in excl: + elif name.lower() in excl: inactive_servers.append(name) else: active_servers.append(name) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 8a97d577c0c..9d05edc0665 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -939,10 +939,10 @@ def get_announcements(self): for server_name, server_tools in self.mcp_tools: if ( self.registered_servers["included"] - and server_name not in self.registered_servers["included"] + and server_name.lower() not in self.registered_servers["included"] ): continue - if server_name in self.registered_servers["excluded"]: + if server_name.lower() in self.registered_servers["excluded"]: continue mcp_servers.append(server_name) if mcp_servers: @@ -3356,10 +3356,10 @@ def get_tool_list(self): # Apply per-instance server filtering if ( self.registered_servers["included"] - and server_name not in self.registered_servers["included"] + and server_name.lower() not in self.registered_servers["included"] ): continue - if server_name in self.registered_servers["excluded"]: + if server_name.lower() in self.registered_servers["excluded"]: continue for tool in server_tools: diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index daee627e738..2f39d013e8e 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -565,11 +565,19 @@ async def _create_sub_agent_coder( ) if agent_config: + # Reset the per-instance tool/server filters so AgentCoder.post_init() + # rebuilds them from *this* sub-agent's own agent-config (tools_includelist/ + # excludelist, servers_includelist/excludelist) instead of inheriting the + # parent's filters verbatim. Deliberately do NOT reset mcp_manager here: + # doing so used to discard the parent's already-connected MCP servers and + # replace them with a brand new, empty manager, making it impossible for a + # sub-agent's servers_includelist to ever match anything. Leaving mcp_manager + # unset lets it inherit from from_coder (see Coder.create()) so the same + # connected MCP servers remain available and can then be filtered. kwargs.update( dict( registered_tools=None, registered_servers=None, - mcp_manager=None, ) ) From b83b808658a13c7946d39c6b696112f1b5157f0f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 12:54:45 +0200 Subject: [PATCH 05/15] Wrap bare JSON array tool-call arguments under the tool's required array param instead of dropping them --- cecli/helpers/responses.py | 19 +++++++++++-- cecli/tools/utils/base_tool.py | 38 ++++++++++++++------------ cecli/tools/validations/validations.py | 24 ++++++++++++---- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index e455d08db03..ba1155a7ad9 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -366,11 +366,18 @@ def unprefix_tool_call(tool_call): return server_name, result -def parse_tool_arguments(args_string: str) -> dict: +def parse_tool_arguments(args_string: str) -> dict | list: """Parse tool-call arguments, merging glued ``{…}{} {…}`` object fragments. Also unwraps a single ``arguments``/``parameters``/``params`` wrapper key that some models emit when mirroring the OpenAI wire format. + + Returns a dict in almost all cases. A bare top-level JSON array (e.g. a + model emitting ``[...]`` directly as the arguments instead of wrapping it + under the tool's single required array parameter, as EditFile's `edits` + or ReadFile's `read`) is returned as-is rather than collapsed to ``{}``, + so ``BaseTool.process_response`` can still wrap it using the tool's + schema instead of silently losing the data. """ text = (args_string or "").strip() if not text: @@ -379,6 +386,8 @@ def parse_tool_arguments(args_string: str) -> dict: parsed = json.loads(text) if isinstance(parsed, dict): return coerce_tool_structure(parsed) + if isinstance(parsed, list): + return parsed except json.JSONDecodeError: pass @@ -393,12 +402,18 @@ def parse_tool_arguments(args_string: str) -> dict: lone = try_parse_json_value(chunks[0]) if isinstance(lone, dict): return coerce_tool_structure(lone) + if isinstance(lone, list): + return lone try: json_string = json_repair.repair_json(chunks[0], ensure_ascii=False) single = json.loads(json_string) except json.JSONDecodeError as err: return {"@error": f"Malformed JSON arguments: {err}"} - return coerce_tool_structure(single) if isinstance(single, dict) else {} + if isinstance(single, dict): + return coerce_tool_structure(single) + if isinstance(single, list): + return single + return {} merged = merge_glued_json_objects(chunks) diff --git a/cecli/tools/utils/base_tool.py b/cecli/tools/utils/base_tool.py index b749c148ad6..3cdebd882df 100644 --- a/cecli/tools/utils/base_tool.py +++ b/cecli/tools/utils/base_tool.py @@ -63,25 +63,29 @@ def process_response(cls, coder, params, _convert=True): required_params = function_schema["parameters"]["required"] properties = function_schema["parameters"].get("properties", {}) - # Auto-correction: If a required parameter is missing but it's an array, - # and the current params look like a single item of that array, wrap it. - if len(required_params) == 1: - missing_param = required_params[0] - if missing_param not in params and params: - param_schema = properties.get(missing_param, {}) - if param_schema.get("type") == "array": - params = {missing_param: [params]} + # Auto-correction: fix common shape mistakes (a bare array or + # a single item of that array sent directly as the whole + # arguments, instead of wrapped under the expected key) BEFORE + # checking for missing required parameters. Otherwise a + # recoverable shape (e.g. a bare `[...]` array) gets rejected + # before it has a chance to be normalized. + params = ToolValidations._basic_validations(params, cls.SCHEMA) # Auto-correction: If a required parameter is present but is a dict instead of an array - for param_name in required_params: - if param_name in params: - param_schema = properties.get(param_name, {}) - if param_schema.get("type") == "array" and isinstance( - params[param_name], dict - ): - params[param_name] = [params[param_name]] - - missing_params = [param for param in required_params if param not in params] + if isinstance(params, dict): + for param_name in required_params: + if param_name in params: + param_schema = properties.get(param_name, {}) + if param_schema.get("type") == "array" and isinstance( + params[param_name], dict + ): + params[param_name] = [params[param_name]] + + missing_params = [ + param + for param in required_params + if not isinstance(params, dict) or param not in params + ] if missing_params: tool_name = function_schema.get("name", "Unknown Tool") error_msg = ( diff --git a/cecli/tools/validations/validations.py b/cecli/tools/validations/validations.py index e6dc8e5cd90..f23306fc419 100644 --- a/cecli/tools/validations/validations.py +++ b/cecli/tools/validations/validations.py @@ -198,16 +198,28 @@ def _basic_validations(cls, params: object, schema: dict | None = None) -> dict: parameters = function_schema["parameters"] properties = parameters.get("properties", {}) - - # Only auto-correct when there is exactly one property and it is an array - if len(properties) == 1: + required = parameters.get("required", []) + + # Determine the single array-typed parameter to auto-correct into, + # if any. Prefer the schema's `required` list (covers tools that also + # declare optional properties alongside their one required array, + # e.g. EditFile's optional `change_id`); fall back to "exactly one + # property total" when the schema doesn't declare `required` at all. + single_param_name = None + if len(required) == 1: + single_param_name = required[0] + elif not required and len(properties) == 1: single_param_name = next(iter(properties.keys())) - param_schema = properties[single_param_name] + + if single_param_name is not None: + param_schema = properties.get(single_param_name, {}) if param_schema.get("type") == "array": - # Case 1: LLM emitted the array directly (bare list) + # Case 1: LLM emitted the array directly (bare list) → wrap + # it as-is under the expected key, don't nest it again. if isinstance(params, list): return {single_param_name: params} - # Case 2: LLM emitted a dict missing the expected key → wrap it + # Case 2: LLM emitted a single item of the array directly + # as a dict, missing the expected wrapper key → wrap it. if isinstance(params, dict) and single_param_name not in params: return {single_param_name: [params]} From c99e52c497ccb077cbb1e8492d36cee4549f1717 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 18 Aug 2026 07:27:10 -0400 Subject: [PATCH 06/15] Update System Prompts --- cecli/prompts/agent.yml | 5 +++-- cecli/prompts/subagent.yml | 5 +++-- cecli/tools/read_file.py | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 5c429fd580a..ae40a12e71b 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -31,8 +31,6 @@ main_system: | - **Unique Lines (`——`):** Lines that appear only once in the file are prefixed with `——`. To target these lines for edits, you can simply reference the exact literal text of the line, excluding the prefix. - **Duplicate Lines (e.g., `—“0车加—`):** Lines that appear multiple times are prefixed with an opaque identifier. You MUST include this exact identifier when targeting these lines to disambiguate which specific instance you want to edit. - Do not attempt to generate, guess, or calculate these identifiers yourself. Always use the exact line contents and prefixes provided from the most recent file read. - **Example File** ``` ——#!/usr/bin/env python3 @@ -52,6 +50,9 @@ main_system: | —니0车加— return None ``` + Do not attempt to generate, guess, or calculate these identifiers yourself. Always use the exact line contents and prefixes provided from the most recent file read. + This system removes the need to recite full file contents. + ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index b8b37358270..f9258c1362a 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -16,8 +16,6 @@ main_system: | - **Unique Lines (`——`):** Lines that appear only once in the file are prefixed with `——`. To target these lines for edits, you can simply reference the exact literal text of the line, excluding the prefix. - **Duplicate Lines (e.g., `—“0车加—`):** Lines that appear multiple times are prefixed with an opaque identifier. You MUST include this exact identifier when targeting these lines to disambiguate which specific instance you want to edit. - Do not attempt to generate, guess, or calculate these identifiers yourself. Always use the exact line contents and prefixes provided from the most recent file read. - **Example File** ``` ——#!/usr/bin/env python3 @@ -37,6 +35,9 @@ main_system: | —니0车加— return None ``` + Do not attempt to generate, guess, or calculate these identifiers yourself. Always use the exact line contents and prefixes provided from the most recent file read. + This system removes the need to recite full file contents. + ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. diff --git a/cecli/tools/read_file.py b/cecli/tools/read_file.py index ae24a035fd6..93721d266d2 100644 --- a/cecli/tools/read_file.py +++ b/cecli/tools/read_file.py @@ -50,8 +50,7 @@ class Tool(BaseTool): " - when range_start matches one location, range_end accepts '@C{num}' (context both sides), " " '@P{num}' (lines before the match), '@N{num}' (lines after the match)" "" - "Identifiers are deterministic per line content, so adding or removing lines can re-prefix " - "identical lines elsewhere in the file; re-read after editing to get fresh identifiers." + "File edits may update prefixes of identical lines, requiring re-reading to get fresh identifiers." "" "Large structured ranges (line-number or boundary reads) return a structural outline " "instead of full contents; read in smaller targeted ranges for full detail." From 74c3305dd2bb437e367f0ea6989032ee4439c3a4 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 18 Aug 2026 07:38:30 -0400 Subject: [PATCH 07/15] Coerece tool structure in validations as well so format_output recieves same arguments as executre --- cecli/tools/validations/validations.py | 6 ++++ tests/tools/test_edit_file_format_output.py | 39 ++++++++++++++++++--- tests/tools/validations.py | 31 ++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/cecli/tools/validations/validations.py b/cecli/tools/validations/validations.py index e6dc8e5cd90..17a2de434de 100644 --- a/cecli/tools/validations/validations.py +++ b/cecli/tools/validations/validations.py @@ -60,6 +60,12 @@ def validate_params(cls, params: dict, validations: dict, schema: dict | None = if not isinstance(params, (dict, list)): raise ToolError("Invalid Tool Input - Unparsable JSON") + # Mirror the execution path (responses.parse_tool_arguments): some models + # double-wrap the real params under a single "arguments"/"parameters"/"params" + # key. Unwrap so previews resolve the same args that will actually execute. + if isinstance(params, dict): + params = responses.coerce_tool_structure(params) + # Apply basic structural corrections before declarative validations params = cls._basic_validations(params, schema) diff --git a/tests/tools/test_edit_file_format_output.py b/tests/tools/test_edit_file_format_output.py index d63e45b83d9..5adc9e1ee1d 100644 --- a/tests/tools/test_edit_file_format_output.py +++ b/tests/tools/test_edit_file_format_output.py @@ -101,23 +101,28 @@ def coder_with_file(tmp_path): return coder, file_path -def make_tool_response(edits): +def make_tool_response(edits, wrap_arguments=False): + arguments = {"edits": edits} + if wrap_arguments: + # Some models mirror the OpenAI wire format and double-wrap the real + # params under a single top-level "arguments" key. + arguments = {"arguments": json.dumps(arguments)} return SimpleNamespace( id="test-id", type="function", function=SimpleNamespace( name="EditFile", - arguments=json.dumps({"edits": edits}), + arguments=json.dumps(arguments), ), ) -def preview_output(coder, edits): +def preview_output(coder, edits, wrap_arguments=False): """Run format_output (as base_coder does before execute) and capture output.""" edit_file.Tool.format_output( coder, mcp_server=SimpleNamespace(name="Local"), - tool_response=make_tool_response(edits), + tool_response=make_tool_response(edits, wrap_arguments=wrap_arguments), ) return "\n".join(coder.io.outputs) @@ -236,3 +241,29 @@ def test_format_output_mixed_selectors_in_batch(coder_with_file): result = edit_file.Tool.execute(coder, edits=edits) assert result.to_dict()["errors"] == [] + + +def test_format_output_unwraps_double_wrapped_arguments(coder_with_file): + """Double-wrapped {"arguments": "{\"edits\": [...]}"} previews a real diff. + + The execution path unwraps this via responses.parse_tool_arguments, so the + preview (format_output) must resolve the same edits instead of rendering + nothing. + """ + coder, _ = coder_with_file + edits = [ + { + "file_path": "example.txt", + "operation": "replace", + "start_line": "@L2", + "end_line": "@L3", + "text": ' print("hello") # edited', + } + ] + + output = preview_output(coder, edits, wrap_arguments=True) + assert "Preview Unavailable" not in output + assert "@@" in output, f"expected a unified diff in preview output:\n{output}" + + result = edit_file.Tool.execute(coder, edits=edits) + assert result.to_dict()["errors"] == [] diff --git a/tests/tools/validations.py b/tests/tools/validations.py index 9f651e75586..bd2ef4138cc 100644 --- a/tests/tools/validations.py +++ b/tests/tools/validations.py @@ -311,6 +311,37 @@ def test_none_validations_returns_params(self): result = ToolValidations.validate_params(params, None) assert result == {"key": "value"} + def test_arguments_wrapper_string_value_unwrapped(self): + """A single top-level "arguments" wrapper (as JSON string) is unwrapped. + + Mirrors the execution path (responses.parse_tool_arguments) so previews + resolve the same args that will actually run. + """ + params = '{"arguments": "{"edits": [{"file_path": "a.txt"}]}"}' + result = ToolValidations.validate_params( + params, + {"edits": ["coerce_list"], "edits[]": ["coerce_dict"]}, + ) + assert result == {"edits": [{"file_path": "a.txt"}]} + + def test_parameters_wrapper_dict_value_unwrapped(self): + """A single top-level "parameters" wrapper (as dict) is unwrapped.""" + params = {"parameters": {"edits": [{"file_path": "a.txt"}]}} + result = ToolValidations.validate_params( + params, + {"edits": ["coerce_list"], "edits[]": ["coerce_dict"]}, + ) + assert result == {"edits": [{"file_path": "a.txt"}]} + + def test_multi_key_params_not_unwrapped(self): + """Params with more than one key are never treated as a wrapper.""" + params = {"edits": [{"file_path": "a.txt"}], "change_id": "x"} + result = ToolValidations.validate_params( + params, + {"edits": ["coerce_list"], "edits[]": ["coerce_dict"]}, + ) + assert result == {"edits": [{"file_path": "a.txt"}], "change_id": "x"} + # ---- simple keys ---- def test_simple_key_coerce_list(self): From 3477cad895e755095f1c9e5c4d569e814a8173c4 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 18 Aug 2026 07:43:12 -0400 Subject: [PATCH 08/15] Update tests that were dependent on MODEL_INFO_URL --- tests/basic/test_model_info_manager.py | 47 +++----------------------- 1 file changed, 5 insertions(+), 42 deletions(-) diff --git a/tests/basic/test_model_info_manager.py b/tests/basic/test_model_info_manager.py index f7c802d298d..73af8fd6ea8 100644 --- a/tests/basic/test_model_info_manager.py +++ b/tests/basic/test_model_info_manager.py @@ -2,7 +2,7 @@ import tempfile from pathlib import Path from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import patch from cecli.models import ModelInfoManager @@ -22,24 +22,6 @@ def tearDown(self): os.environ.clear() os.environ.update(self.original_env) - @patch("requests.get") - def test_update_cache_respects_verify_ssl(self, mock_get): - # Setup mock response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"test_model": {"max_tokens": 4096}} - mock_get.return_value = mock_response - - # Test with default verify_ssl=True - self.manager._update_cache() - mock_get.assert_called_with(self.manager.MODEL_INFO_URL, timeout=5, verify=True) - - # Test with verify_ssl=False - mock_get.reset_mock() - self.manager.set_verify_ssl(False) - self.manager._update_cache() - mock_get.assert_called_with(self.manager.MODEL_INFO_URL, timeout=5, verify=False) - def test_lazy_loading_cache(self): # Create a cache file self.manager.cache_file.write_text('{"test_model": {"max_tokens": 4096}}') @@ -49,32 +31,13 @@ def test_lazy_loading_cache(self): self.assertIsNone(self.manager.content) # Access content through get_model_from_cached_json_db - with patch.object(self.manager, "_update_cache") as mock_update: + with patch.object(self.manager, "_load_cache", wraps=self.manager._load_cache) as mock_load: result = self.manager.get_model_from_cached_json_db("test_model") - # Verify cache was loaded + # Verify cache was loaded lazily on first access self.assertTrue(self.manager._cache_loaded) self.assertIsNotNone(self.manager._raw_content) self.assertEqual(result, {"max_tokens": 4096}) - # Verify _update_cache was not called since cache exists and is valid - mock_update.assert_not_called() - - @patch("requests.get") - def test_verify_ssl_setting_before_cache_loading(self, mock_get): - # Setup mock response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"test_model": {"max_tokens": 4096}} - mock_get.return_value = mock_response - - # Set verify_ssl to False before any cache operations - self.manager.set_verify_ssl(False) - - # Force cache update by making it look expired - with patch("time.time", return_value=9999999999): - # This should trigger _update_cache - self.manager.get_model_from_cached_json_db("test_model") - - # Verify _update_cache was called with verify=False - mock_get.assert_called_with(self.manager.MODEL_INFO_URL, timeout=5, verify=False) + # Verify _load_cache was called exactly once (lazy load on demand) + mock_load.assert_called_once() From b389174f60edf0ea0fc24928dca5fc11a72dde36 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 15:32:37 +0200 Subject: [PATCH 09/15] show explicit placeholder in Command format_output when no command or background action provided --- cecli/tools/command.py | 8 ++ tests/tools/test_command_format_output.py | 117 ++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/tools/test_command_format_output.py diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 3712fcd5c0a..4f581db0d64 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -722,6 +722,14 @@ def format_output(cls, coder, mcp_server, tool_response): elif command: coder.io.tool_output(f"{color_start}Command:{color_end}") coder.io.tool_output(coder.format_command_with_prefix(command)) + else: + # No command and no background_key/action pair: this call will + # be rejected by execute() before it ever reaches confirmation. + # Say so explicitly instead of leaving the panel looking blank, + # which is otherwise indistinguishable from a display bug. + coder.io.tool_output( + f"{color_start}(no command provided — call will be rejected){color_end}" + ) coder.io.tool_output("") diff --git a/tests/tools/test_command_format_output.py b/tests/tools/test_command_format_output.py new file mode 100644 index 00000000000..7e5b3423dd0 --- /dev/null +++ b/tests/tools/test_command_format_output.py @@ -0,0 +1,117 @@ +"""Regression tests for Command.format_output rendering. + +format_output() prints the "Tool Call:" header before execute() runs, so it +must clearly communicate what execute() is about to do — including the case +where the LLM emits an empty/no-op call (no `command` and no +`background_key`/`action` pair). Without an explicit message, such a call +renders a header with a blank body, which looks identical (from the user's +perspective) to a genuine display bug where an auto-approved command call +fails to print its body at all. +""" + +import json +from types import SimpleNamespace + +import pytest + +from cecli.tools import command + + +class DummyIO: + def __init__(self): + self.outputs = [] + self._last_type = None + + def tool_output(self, msg="", type=None, **kwargs): + self.outputs.append(str(msg)) + self._last_type = type + + def tool_error(self, msg="", **kwargs): + self.outputs.append(f"ERROR: {msg}") + + +class DummyCoder: + def __init__(self): + self.io = DummyIO() + self.pretty = False + self.verbose = False + + def format_command_with_prefix(self, cmd): + return cmd + + +def make_tool_response(args): + return SimpleNamespace( + id="test-id", + type="function", + function=SimpleNamespace( + name="Command", + arguments=json.dumps(args), + ), + ) + + +def render(coder, args): + command.Tool.format_output( + coder, + mcp_server=SimpleNamespace(name="Local"), + tool_response=make_tool_response(args), + ) + return "\n".join(coder.io.outputs) + + +def test_format_output_shows_command_text(): + coder = DummyCoder() + output = render(coder, {"command": "echo hi"}) + assert "Tool Call:" in output + assert "Command:" in output + assert "echo hi" in output + + +def test_format_output_empty_args_shows_explicit_placeholder(): + """Regression test: an empty-args call (no command, no background_key/action) + must not render a header with a silently blank body. + + Reproduces a real session where the model called `Command` with `{}` — + execute() correctly rejects this ("'command' must be provided."), but the + displayed panel showed nothing after the header, which was mistaken for + a display bug (auto-approved commands failing to render their body). + """ + coder = DummyCoder() + output = render(coder, {}) + + assert "Tool Call:" in output + assert "no command provided" in output + # Must not silently look identical to a "nothing rendered" panel. + assert "Command:" not in output + + +def test_format_output_background_key_action_shown_without_command(): + coder = DummyCoder() + output = render(coder, {"background_key": "bg-123", "action": "stop"}) + + assert "Tool Call:" in output + assert "Background Key:" in output + assert "bg-123" in output + assert "Action:" in output + assert "stop" in output + assert "no command provided" not in output + + +@pytest.mark.asyncio +async def test_execute_rejects_empty_args_matching_format_output_case(): + """execute() must actually reject the same empty-args shape that + format_output flags, keeping the two code paths consistent.""" + + class ExecCoder(DummyCoder): + skip_cli_confirmations = True + + def __init__(self): + super().__init__() + self.agent_config = {} + + coder = ExecCoder() + response = await command.Tool.execute(coder) + result = response.to_dict() + assert result["result"] == [] + assert "'command' must be provided." in result["errors"] From 0cce57c1b8208c7067fb545241fa84b5bf17f790 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 19 Aug 2026 07:56:40 -0400 Subject: [PATCH 10/15] Normalize MCP server names to lowercase at registration sources PR #649 made sub-agent MCP filtering work by lowercasing server names at each comparison site. Move that normalization to the registration sources so all names are consistently lowercase and every filtering comparison is a plain exact match: - McpServer.__init__ lowercases the configured server name (source of truth); get_server() becomes case-insensitive so user input in any case still resolves. - update_server_registration() and is_server_globally_excluded() store and compare lowercase names. - load-mcp include-set conversion lowercases connected server names and uses 'local' for the always-available Local server (also in the ResourceManager tool variant). - Revert comparison-site lowercasing in get_tool_list(), get_announcements(), and get_servers_context(); exact match now works because names are lowercase at the source. - Local server literal checks are case-insensitive ('local') in remove-mcp, the ResourceManager tool, and manager warnings. Adds tests/mcp/test_server_name_normalization.py covering name lowercasing, case-insensitive lookups, registration lowercasing, exact- match filtering, servers-context classification, and the load-mcp conversion. --- cecli/coders/agent_coder.py | 4 +- cecli/coders/base_coder.py | 10 +- cecli/commands/load_mcp.py | 4 +- cecli/commands/remove_mcp.py | 2 +- cecli/commands/utils/helpers.py | 4 +- cecli/mcp/manager.py | 6 +- cecli/mcp/server.py | 2 +- cecli/tools/resource_manager.py | 8 +- tests/mcp/test_server_name_normalization.py | 240 ++++++++++++++++++++ 9 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 tests/mcp/test_server_name_normalization.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index c58aa5bd1ab..08370f38e46 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1627,9 +1627,9 @@ def get_servers_context(self): inactive_servers = [] for server in connected_servers: name = server.name - if incl and name.lower() not in incl: + if incl and name not in incl: inactive_servers.append(name) - elif name.lower() in excl: + elif name in excl: inactive_servers.append(name) else: active_servers.append(name) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 9d05edc0665..31983d75c6f 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -939,10 +939,10 @@ def get_announcements(self): for server_name, server_tools in self.mcp_tools: if ( self.registered_servers["included"] - and server_name.lower() not in self.registered_servers["included"] + and server_name not in self.registered_servers["included"] ): continue - if server_name.lower() in self.registered_servers["excluded"]: + if server_name in self.registered_servers["excluded"]: continue mcp_servers.append(server_name) if mcp_servers: @@ -3356,14 +3356,14 @@ def get_tool_list(self): # Apply per-instance server filtering if ( self.registered_servers["included"] - and server_name.lower() not in self.registered_servers["included"] + and server_name not in self.registered_servers["included"] ): continue - if server_name.lower() in self.registered_servers["excluded"]: + if server_name in self.registered_servers["excluded"]: continue for tool in server_tools: - if server_name == "Local": + if server_name.lower() == "local": # Apply per-instance tool name filtering tool_name = tool.get("function", {}).get("name", "") if ( diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index 3f7259ddfbd..55e244fa8ef 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -60,13 +60,13 @@ async def execute(cls, io, coder, args, **kwargs): # This moves them from "implicitly include all" to explicit state-machine # management, preventing the new server from being implicitly available # to all coders. - connected_names = {s.name for s in coder.mcp_manager.connected_servers} + connected_names = {s.name.lower() for s in coder.mcp_manager.connected_servers} if connected_names: for c in iter_all_coders(coder): if not c.registered_servers["included"]: included = set(connected_names) - c.registered_servers["excluded"] if c.edit_format in ("agent", "subagent"): - included.add("Local") # "local" is always available + included.add("local") # "local" is always available c.registered_servers["included"] = included # Process connections with interrupt support diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index 2b32307ecd1..05f23de8f11 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -49,7 +49,7 @@ async def execute(cls, io, coder, args, **kwargs): server_name = item.name if hasattr(item, "name") else item # Never remove the "local" server - if server_name == "Local": + if server_name.lower() == "local": results.append("Cannot remove 'Local' server") continue diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index d2fa2b4184c..b70fa0c40c1 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -348,7 +348,7 @@ def update_server_registration(coder, server_name, operation, force=False): When False, respect the opposing set (excluded wins for include, included wins for exclude). """ - name = server_name + name = server_name.lower() included = coder.registered_servers["included"] excluded = coder.registered_servers["excluded"] @@ -379,7 +379,7 @@ def is_server_globally_excluded(coder, server_name): Returns: True if the server is excluded from every coder. """ - name = server_name + name = server_name.lower() for other in iter_all_coders(coder): incl = other.registered_servers["included"] excl = other.registered_servers["excluded"] diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py index 26e30decf38..0e361289516 100644 --- a/cecli/mcp/manager.py +++ b/cecli/mcp/manager.py @@ -99,7 +99,7 @@ def get_server(self, name: str) -> McpServer | None: The server instance or None if not found """ try: - return next(server for server in self._servers if server.name == name) + return next(server for server in self._servers if server.name.lower() == name.lower()) except StopIteration: return None @@ -254,7 +254,7 @@ async def add_server(self, server: McpServer, connect: bool = False) -> bool: """ existing_server = self.get_server(server.name) if existing_server: - if server.name not in ["unnamed-server", "Local"]: + if server.name.lower() not in ["unnamed-server", "local"]: self._log_warning(f"MCP server with name '{server.name}' already exists") return False @@ -332,7 +332,7 @@ async def add_server_with_retry( results = await asyncio.gather(*tasks) for server, did_connect in results: - if not did_connect and server.name not in ["unnamed-server", "Local"]: + if not did_connect and server.name.lower() not in ["unnamed-server", "local"]: io.tool_warning( f"MCP tool initialization failed after multiple retries: {server.name}" ) diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index f96fed7baef..88faa585ceb 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -54,7 +54,7 @@ def __init__(self, server_config, io=None, verbose=False): verbose: Whether to output verbose logging """ self.config = server_config - self.name = server_config.get("name", "unnamed-server") + self.name = str(server_config.get("name", "unnamed-server")).lower() self.io = io self.verbose = verbose self.session = None diff --git a/cecli/tools/resource_manager.py b/cecli/tools/resource_manager.py index aedc10cc203..70bff8194ad 100644 --- a/cecli/tools/resource_manager.py +++ b/cecli/tools/resource_manager.py @@ -199,15 +199,15 @@ async def execute( # included sets to explicit include lists. if load_mcp_servers and coder.mcp_manager: if isinstance(coder.mcp_manager.connected_servers, dict): - connected_names = set(coder.mcp_manager.connected_servers.keys()) + connected_names = {k.lower() for k in coder.mcp_manager.connected_servers.keys()} else: - connected_names = {s.name for s in coder.mcp_manager.connected_servers} + connected_names = {s.name.lower() for s in coder.mcp_manager.connected_servers} if connected_names: for c in iter_all_coders(coder): if not c.registered_servers["included"]: included = set(connected_names) - c.registered_servers["excluded"] if c.edit_format in ("agent", "subagent"): - included.add("Local") + included.add("local") c.registered_servers["included"] = included for f in create_files: @@ -546,7 +546,7 @@ async def _remove_mcp(cls, coder, server_name): if not coder.mcp_manager or not coder.mcp_manager.servers: return "No MCP servers are configured." - if server_name == "Local": + if server_name.lower() == "local": return "Cannot remove 'Local' server" server = coder.mcp_manager.get_server(server_name) diff --git a/tests/mcp/test_server_name_normalization.py b/tests/mcp/test_server_name_normalization.py new file mode 100644 index 00000000000..ad16ba13cc7 --- /dev/null +++ b/tests/mcp/test_server_name_normalization.py @@ -0,0 +1,240 @@ +"""Tests for lowercase-normalized MCP server names. + +MCP server names are normalized to lowercase at the registration source +(``McpServer`` construction, ``update_server_registration``, and the +load/remove-mcp include-set conversions) so that all per-coder filtering +comparisons can be plain exact matches. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.coders import Coder +from cecli.coders.agent_coder import AgentCoder +from cecli.commands import SwitchCoderSignal +from cecli.commands.load_mcp import LoadMcpCommand +from cecli.commands.utils.helpers import ( + is_server_globally_excluded, + update_server_registration, +) +from cecli.mcp.manager import McpServerManager +from cecli.mcp.server import LocalServer, McpServer + + +def _tool(name): + """Build a minimal OpenAI-style function tool dict.""" + return { + "type": "function", + "function": {"name": name, "description": "", "parameters": {}}, + } + + +class TestMcpServerNameLowercasedAtSource: + """McpServer names are lowercased at construction time.""" + + def test_mcp_server_name_lowercased(self): + assert McpServer({"name": "GitHub"}).name == "github" + + def test_local_server_name_lowercased(self): + assert LocalServer({"name": "Local"}).name == "local" + + def test_default_name_stays_lowercase(self): + assert McpServer({}).name == "unnamed-server" + + +class TestGetServerCaseInsensitive: + """Manager lookups tolerate any case, matching normalized names.""" + + def test_get_server_matches_any_case(self): + server = McpServer({"name": "GitHub"}) + manager = McpServerManager(servers=[server]) + + assert manager.get_server("GitHub") is server + assert manager.get_server("github") is server + assert manager.get_server("GITHUB") is server + + def test_get_server_missing_returns_none(self): + manager = McpServerManager(servers=[McpServer({"name": "GitHub"})]) + + assert manager.get_server("missing") is None + + +class TestUpdateServerRegistrationLowercases: + """Registration writes lowercase names into the per-coder sets.""" + + def test_include_lowercases(self): + coder = SimpleNamespace(registered_servers={"included": set(), "excluded": set()}) + + update_server_registration(coder, "GitHub", "include", force=True) + + assert coder.registered_servers["included"] == {"github"} + assert coder.registered_servers["excluded"] == set() + + def test_exclude_lowercases(self): + coder = SimpleNamespace(registered_servers={"included": set(), "excluded": set()}) + + update_server_registration(coder, "GITHUB", "exclude", force=True) + + assert coder.registered_servers["excluded"] == {"github"} + assert coder.registered_servers["included"] == set() + + def test_force_false_respects_opposing_set(self): + coder = SimpleNamespace(registered_servers={"included": set(), "excluded": {"github"}}) + + update_server_registration(coder, "GitHub", "include", force=False) + + assert coder.registered_servers["included"] == set() + assert coder.registered_servers["excluded"] == {"github"} + + +class TestIsServerGloballyExcludedCaseInsensitive: + """Global-exclusion checks match lowercase registered names.""" + + def test_included_server_is_not_globally_excluded(self): + coder = SimpleNamespace(registered_servers={"included": {"github"}, "excluded": set()}) + + with patch("cecli.commands.utils.helpers.iter_all_coders", return_value=[coder]): + assert is_server_globally_excluded(coder, "GitHub") is False + + def test_excluded_server_is_globally_excluded(self): + coder = SimpleNamespace(registered_servers={"included": set(), "excluded": {"github"}}) + + with patch("cecli.commands.utils.helpers.iter_all_coders", return_value=[coder]): + assert is_server_globally_excluded(coder, "GITHUB") is True + + +def _manager_with_servers(): + """Manager with real servers named 'GitHub'/'Local' (normalized to lowercase).""" + github = McpServer({"name": "GitHub"}) + local = McpServer({"name": "Local"}) + manager = McpServerManager(servers=[github, local]) + manager._server_tools = { + "github": [_tool("list_issues")], + "local": [_tool("read_file")], + } + manager._connected_servers = {github, local} + return manager + + +class TestGetToolListExactMatchFiltering: + """Per-coder filtering uses exact match on lowercase names.""" + + def _coder(self, included=None, excluded=None): + manager = _manager_with_servers() + return SimpleNamespace( + mcp_tools=list(manager.all_tools.items()), + registered_servers={ + "included": set(included or []), + "excluded": set(excluded or []), + }, + registered_tools={"included": set(), "excluded": set()}, + ) + + def test_include_list_keeps_only_matching_server(self): + coder = self._coder(included=["github"]) + + names = [t["function"]["name"] for t in Coder.get_tool_list(coder)] + + assert names == ["github--list_issues"] + + def test_empty_include_includes_all(self): + coder = self._coder() + + names = [t["function"]["name"] for t in Coder.get_tool_list(coder)] + + assert set(names) == {"github--list_issues", "local--read_file"} + + def test_exclude_list_filters_by_lowercase(self): + coder = self._coder(excluded=["github"]) + + names = [t["function"]["name"] for t in Coder.get_tool_list(coder)] + + assert names == ["local--read_file"] + + +class TestGetServersContextExactMatchFiltering: + """Servers context block classifies servers using exact lowercase match.""" + + def _coder(self, included=None, excluded=None): + return SimpleNamespace( + use_enhanced_context=True, + io=MagicMock(), + mcp_manager=_manager_with_servers(), + registered_servers={ + "included": set(included or []), + "excluded": set(excluded or []), + }, + ) + + def test_include_list_marks_others_inactive(self): + coder = self._coder(included=["github"]) + + ctx = AgentCoder.get_servers_context(coder) + + assert "Active (1):" in ctx + assert "- github" in ctx + assert "Inactive (Filtered) (1):" in ctx + assert "- local" in ctx + + def test_empty_include_lists_all_active(self): + coder = self._coder() + + ctx = AgentCoder.get_servers_context(coder) + + assert "Active (2):" in ctx + assert "Inactive (Filtered)" not in ctx + + +class TestLoadMcpCommandConversionLowercases: + """Empty include sets are converted to lowercase connected names + 'local'.""" + + @pytest.mark.asyncio + async def test_empty_include_converted_to_lowercase(self): + coder = MagicMock() + coder.io = MagicMock() + coder.edit_format = "agent" + coder.interrupt_event = MagicMock() + coder.interrupt_event.clear = MagicMock() + coder.registered_servers = {"included": set(), "excluded": set()} + + github = MagicMock() + github.name = "GitHub" + github.config = {"enabled": True} + local = MagicMock() + local.name = "Local" + local.config = {} + + coder.mcp_manager = MagicMock() + coder.mcp_manager.servers = [github, local] + coder.mcp_manager.connected_servers = [github, local] + coder.mcp_manager.get_server = MagicMock(return_value=github) + coder.mcp_manager.connect_server = AsyncMock(return_value=True) + + async def _passthrough(coro, event): + return await coro, False + + coder.coroutines = MagicMock() + coder.coroutines.interruptible = _passthrough + + with patch("cecli.commands.load_mcp.iter_all_coders", return_value=[coder]): + with pytest.raises(SwitchCoderSignal): + await LoadMcpCommand.execute(coder.io, coder, "GitHub") + + assert coder.registered_servers["included"] == {"github", "local"} + + +class TestLocalServerConnectedUnderLowercaseKey: + """Connecting the Local server stores tools under the lowercase name.""" + + @pytest.mark.asyncio + async def test_connect_local_server_key_is_lowercase(self): + local = LocalServer({"name": "Local"}) + manager = McpServerManager(servers=[local]) + + with patch("cecli.mcp.manager.get_local_tool_schemas", return_value=[_tool("x")]): + assert await manager.connect_server("Local") is True + + assert "local" in manager._server_tools + assert "Local" not in manager._server_tools From e39a10435929175aa20c7cdc45ce7b6e39253c1f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 19 Aug 2026 08:49:02 -0400 Subject: [PATCH 11/15] Use the messages API top level cache_control field instead of per message block calculations --- cecli/helpers/llms/config.py | 4 +- cecli/helpers/llms/domains/messages.py | 150 +---------------------- cecli/helpers/model_config/agent.py | 6 +- tests/helpers/test_llms_cache_control.py | 125 ++++--------------- 4 files changed, 38 insertions(+), 247 deletions(-) diff --git a/cecli/helpers/llms/config.py b/cecli/helpers/llms/config.py index 10cbeedf1d9..eafe632ca7f 100644 --- a/cecli/helpers/llms/config.py +++ b/cecli/helpers/llms/config.py @@ -143,8 +143,8 @@ def resolve_model_config(model: str) -> Dict[str, Any]: # Effective cache flags for the domain adapters. Anthropic messages-API # models never cache "by default": prompt caching only happens when the - # request carries explicit ``cache_control`` breakpoints, so the messages - # domain injects them (agent.cache_control True + caches_by_default False). + # request asks for it, so the messages domain requests top-level automatic + # caching (agent.cache_control True + caches_by_default False). if family == "messages": agent_block["cache_control"] = True agent_block["caches_by_default"] = False diff --git a/cecli/helpers/llms/domains/messages.py b/cecli/helpers/llms/domains/messages.py index f3077608fa7..a80561c5744 100644 --- a/cecli/helpers/llms/domains/messages.py +++ b/cecli/helpers/llms/domains/messages.py @@ -33,9 +33,6 @@ DEFAULT_TIMEOUT = 120.0 -#: Anthropic allows at most 4 ``cache_control`` breakpoints per request. -MAX_CACHE_BREAKPOINTS = 4 - def anthropic_payload( resolved: Dict[str, Any], @@ -98,14 +95,13 @@ def anthropic_payload( extra_body.pop("thinking", None) payload.update(extra_body) - # Anthropic prompt caching is a byte-exact prefix match: it only happens - # when the request carries explicit ``cache_control`` breakpoints. The - # model config flags messages-API models ``cache_control`` True and - # ``caches_by_default`` False (see config.resolve_model_config), so the - # messages domain owns the marking here rather than the conversation - # manager. + # Anthropic prompt caching: request automatic caching via the top-level + # ``cache_control`` field. The model config flags messages-API models + # ``cache_control`` True and ``caches_by_default`` False (see + # config.resolve_model_config), so the messages domain requests it here + # rather than managing explicit per-block breakpoints. if agent_block.get("cache_control") and not agent_block.get("caches_by_default"): - _apply_anthropic_cache_control(payload) + payload["cache_control"] = {"type": "ephemeral"} return payload @@ -627,140 +623,6 @@ def _anthropic_user_blocks(content: List[Dict[str, Any]]) -> List[Dict[str, Any] return blocks -def _apply_anthropic_cache_control(payload: Dict[str, Any]) -> None: - """Attach ephemeral ``cache_control`` breakpoints to a copy of the stream. - - Anthropic prompt caching is a byte-exact prefix match: a breakpoint on the - last content block of a message caches every token up to and including it. - Following the conversation manager's placement (``manager.py - _add_cache_control``), we mark the three stable boundaries of a multi-turn - exchange: - - - the last system block at the start of the stream (which also covers the - tool definitions rendered before it), and - - the last content block of the two most recent non-tool user/assistant - turns. - - The message stream is copied and only the key messages are replaced, so - caller-owned lists are never mutated. Anthropic allows at most - ``MAX_CACHE_BREAKPOINTS`` breakpoints per request, so the marking stops - early once that budget (including any caller-supplied markers) is - exhausted. - """ - import copy - - budget = MAX_CACHE_BREAKPOINTS - _count_cache_breakpoints(payload) - - if budget <= 0: - return - - system = payload.get("system") - - if isinstance(system, str): - payload["system"] = [ - {"type": "text", "text": system, "cache_control": {"type": "ephemeral"}} - ] - budget -= 1 - - elif isinstance(system, list) and system: - system = list(system) - last = system[-1] - - if isinstance(last, dict): - last = copy.deepcopy(last) - system[-1] = last - - if _mark_cache_breakpoint(last): - budget -= 1 - - payload["system"] = system - - messages = payload.get("messages") or [] - result = list(messages) - marked = 0 - - for i in range(len(result) - 1, -1, -1): - if marked >= 2 or budget <= 0: - break - - msg = result[i] - - if _is_tool_turn(msg): - continue - - content = msg.get("content") - - if isinstance(content, str): - msg = copy.deepcopy(msg) - msg["content"] = [ - {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} - ] - result[i] = msg - marked += 1 - budget -= 1 - - elif isinstance(content, list) and content: - msg = copy.deepcopy(msg) - blocks = list(msg["content"]) - - if _mark_cache_breakpoint(blocks[-1]): - marked += 1 - budget -= 1 - - msg["content"] = blocks - result[i] = msg - - payload["messages"] = result - - -def _count_cache_breakpoints(payload: Dict[str, Any]) -> int: - """Count existing ``cache_control`` markers in a payload (system + messages).""" - count = 0 - system = payload.get("system") - - if isinstance(system, list): - count += sum(isinstance(block, dict) and "cache_control" in block for block in system) - - for tool in payload.get("tools") or []: - if isinstance(tool, dict) and "cache_control" in tool: - count += 1 - - for msg in payload.get("messages") or []: - content = msg.get("content") - - if isinstance(content, list): - count += sum(isinstance(block, dict) and "cache_control" in block for block in content) - - return count - - -def _mark_cache_breakpoint(block: Any) -> bool: - """Add an ephemeral breakpoint to ``block`` unless it already has one.""" - if not isinstance(block, dict) or "cache_control" in block: - return False - - block["cache_control"] = {"type": "ephemeral"} - - return True - - -def _is_tool_turn(msg: Dict[str, Any]) -> bool: - """True when a wire message is a tool turn (transient content). - - Matches the conversation manager's placement, which skips tool messages - and assistant tool-call turns when choosing cache breakpoints. - """ - content = msg.get("content") - - if not isinstance(content, list) or not content: - return False - - return any( - isinstance(block, dict) and block.get("type") in ("tool_result", "tool_use") - for block in content - ) - - def _coalesce_anthropic_messages(wire_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Merge consecutive user turns into a single user message. diff --git a/cecli/helpers/model_config/agent.py b/cecli/helpers/model_config/agent.py index 633ce712080..863bf13c251 100644 --- a/cecli/helpers/model_config/agent.py +++ b/cecli/helpers/model_config/agent.py @@ -57,9 +57,9 @@ def derive_agent_config(provider: Optional[str], route: str, record: Optional[Di # assuming caching support. # # Anthropic messages-API models never cache "by default": prompt - # caching only happens when the request carries explicit - # ``cache_control`` breakpoints (injected by the llms messages - # domain), so keep ``caches_by_default`` off for them. + # caching only happens when the request asks for it (the llms + # messages domain requests top-level automatic caching), so keep + # ``caches_by_default`` off for them. "caches_by_default": ( not uses_messages_api and (bool(record.get("cache_read_input_token_cost")) if record else True) diff --git a/tests/helpers/test_llms_cache_control.py b/tests/helpers/test_llms_cache_control.py index 6da9410714b..e97df47ef67 100644 --- a/tests/helpers/test_llms_cache_control.py +++ b/tests/helpers/test_llms_cache_control.py @@ -1,24 +1,16 @@ -"""Cache-control breakpoint injection tests for the anthropic messages domain. +"""Cache-control tests for the anthropic messages domain. -``anthropic_payload`` must attach ephemeral ``cache_control`` breakpoints at the -stable boundaries of a multi-turn exchange (last system block + the two most -recent non-tool user/assistant turns), following the conversation manager's -placement, while: - -- never mutating caller-owned message dicts (key messages are replaced with - copies), and -- never exceeding Anthropic's 4-breakpoint per-request limit, even when the - caller already supplied markers (so breakpoints never pile up between turns). +``anthropic_payload`` must request automatic caching via the top-level +``cache_control`` field for messages-API models (the model config flags them +``cache_control`` True + ``caches_by_default`` False), while chat-family +models keep their own caching semantics and receive no such field. No network: only the offline payload builder and helpers are exercised. """ from cecli.helpers.llms.config import resolve_model_config from cecli.helpers.llms.domains.messages import ( - MAX_CACHE_BREAKPOINTS, _anthropic_usage, - _apply_anthropic_cache_control, - _count_cache_breakpoints, anthropic_payload, ) @@ -42,50 +34,41 @@ ] -def _block_cache_control(block): - return block.get("cache_control") if isinstance(block, dict) else None +def _content_blocks(payload): + """All content blocks in the payload (system + messages), flattened.""" + blocks = [] + system = payload.get("system") -def _last_block(msg): - content = msg["content"] - return content[-1] if isinstance(content, list) else content + if isinstance(system, list): + blocks.extend(system) + for msg in payload.get("messages") or []: + content = msg.get("content") -def _resolve_no_inject(): - """Resolved config with auto-injection disabled (for helper-level tests).""" - resolved = resolve_model_config("github_copilot/claude-sonnet-5") - return dict(resolved, agent_block={"cache_control": False, "caches_by_default": True}) + if isinstance(content, list): + blocks.extend(content) + return blocks -def test_messages_api_injects_system_and_last_two_turns(): + +def test_messages_api_requests_top_level_cache_control(): resolved = resolve_model_config("github_copilot/claude-sonnet-5") payload = anthropic_payload(resolved, MULTI_TURN, None, False, {}) assert resolved["family"] == "messages" - assert isinstance(payload["system"], list) - assert _block_cache_control(payload["system"][-1]) == {"type": "ephemeral"} - - # Tool-result and tool-use turns are skipped; only the last two non-tool - # turns (assistant "second answer", user "current question") carry a - # breakpoint. - marked = [ - i - for i, msg in enumerate(payload["messages"]) - if _block_cache_control(_last_block(msg)) == {"type": "ephemeral"} - ] - assert marked == [5, 6] - assert _count_cache_breakpoints(payload) == 3 - - -def test_single_turn_marks_last_block_only(): + assert payload["cache_control"] == {"type": "ephemeral"} + assert all("cache_control" not in block for block in _content_blocks(payload)) + + +def test_single_turn_requests_top_level_cache_control(): resolved = resolve_model_config("claude-sonnet-5") payload = anthropic_payload(resolved, [{"role": "user", "content": "hi"}], None, False, {}) - assert _block_cache_control(_last_block(payload["messages"][0])) == {"type": "ephemeral"} - assert _count_cache_breakpoints(payload) == 1 + assert payload["cache_control"] == {"type": "ephemeral"} -def test_chat_family_claude_not_injected(): +def test_chat_family_claude_keeps_own_caching_semantics(): resolved = resolve_model_config("openrouter/claude-sonnet-5") payload = anthropic_payload( resolved, @@ -95,63 +78,9 @@ def test_chat_family_claude_not_injected(): {}, ) - # Chat-family claude keeps its own caching semantics: no injection. + # Chat-family claude keeps its own caching semantics: no top-level field. assert resolved["family"] == "chat" - assert isinstance(payload["system"], str) - assert _count_cache_breakpoints(payload) == 0 - - -def test_apply_does_not_mutate_input_messages(): - payload = anthropic_payload(_resolve_no_inject(), MULTI_TURN, None, False, {}) - before = list(payload["messages"]) - _apply_anthropic_cache_control(payload) - - # Original wire message dicts are untouched; marked messages were replaced - # with copies. - for msg in before: - content = msg["content"] - - if isinstance(content, list): - for block in content: - assert "cache_control" not in block - - -def test_breakpoint_budget_never_exceeds_four(): - for pre in range(0, MAX_CACHE_BREAKPOINTS + 1): - payload = anthropic_payload(_resolve_no_inject(), MULTI_TURN, None, False, {}) - - for i in range(pre): - content = payload["messages"][i]["content"] - - if isinstance(content, str): - payload["messages"][i]["content"] = [ - {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} - ] - else: - content[-1]["cache_control"] = {"type": "ephemeral"} - - _apply_anthropic_cache_control(payload) - - assert _count_cache_breakpoints(payload) <= MAX_CACHE_BREAKPOINTS - - -def test_breakpoints_do_not_pile_up_across_turns(): - resolved = resolve_model_config("github_copilot/claude-sonnet-5") - history = [ - {"role": "user", "content": "q1"}, - {"role": "assistant", "content": "a1"}, - ] - - for turn in range(3): - history.append({"role": "user", "content": f"q{turn + 2}"}) - history.append({"role": "assistant", "content": f"a{turn + 2}"}) - payload = anthropic_payload( - resolved, [{"role": "system", "content": SYS}] + history, None, False, {} - ) - - # Always exactly system + the two most recent non-tool turns; older - # history never accumulates extra breakpoints. - assert _count_cache_breakpoints(payload) == 3 + assert "cache_control" not in payload def test_anthropic_usage_normalizes_full_input(): From a1fe9b12130f4003e34abb0ff8df4d88a9e92e97 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 19 Aug 2026 19:14:50 -0400 Subject: [PATCH 12/15] Bump version to retrigger github pipelines --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 3536c41b24f..cf267466b92 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "1.2.0.dev" +__version__ = "1.2.3.dev" safe_version = __version__ try: From 08f1690c3a9151ad5173882d9ff56e714910a04f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 20 Aug 2026 10:50:08 -0400 Subject: [PATCH 13/15] MCP 2.0.0 potentiall causes hang in CI/CD --- cecli/main.py | 7 +++++++ tests/basic/test_main.py | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cecli/main.py b/cecli/main.py index 92694d5e83e..e653582dce8 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1354,6 +1354,13 @@ def get_io(pretty): except ValueError as err: pre_init_io.tool_error(str(err)) return await graceful_exit(None, 1) + + # The coder owns the MCP manager so graceful_exit can disconnect servers. + # If the coder never adopted it (e.g. mocked coders in tests), keep the + # reference so disconnect_all() still runs and MCP client tasks don't + # leak into asyncio.run() shutdown (which hangs with mcp 2.x stdio tasks). + coder.mcp_manager = mcp_manager + if return_coder: return coder ignores = [] diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 31a0e9423cd..c2d7b30c186 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -60,7 +60,6 @@ def test_env(mocker, temp_cwd, temp_home): - Mocked user input and browser opening - Windows compatibility (USERPROFILE vs HOME) - All resources are automatically cleaned up by dependency fixtures and mocker. """ test_env_vars = { "OPENAI_API_KEY": "deadbeef", From f85c98045f5970a7d99d15a9d1bf227038be779a Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 20 Aug 2026 18:35:54 -0400 Subject: [PATCH 14/15] Only inject sub agent states on changes --- cecli/helpers/conversation/integration.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 29948a2f8e8..3ffdc22883e 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -1,5 +1,6 @@ import json import random +import time import weakref from typing import Any, Dict, List @@ -959,13 +960,23 @@ def add_sub_agent_states(self) -> None: if not block: return + if not hasattr(coder, "_last_child_agent_hash") or not coder._last_child_agent_hash: + coder._last_child_agent_hash = "" + + message_hash = xxhash.xxh3_128_hexdigest(block.encode("utf-8")) + + if message_hash == coder._last_child_agent_hash: + return # No change in sub-agent states, skip adding message + + coder._last_child_agent_hash = message_hash + ConversationService.get_manager(coder).add_message( message_dict={"role": "user", "content": block}, tag=MessageTag.STATIC, - priority=DEFAULT_TAG_PRIORITY[MessageTag.REMINDER] + 25, # After post_message blocks - mark_for_delete=0, - hash_key=("sub_agent_states",), - force=True, + priority=DEFAULT_TAG_PRIORITY[ + MessageTag.CUR + ], # Inject on change in normal message sequence + hash_key=("sub_agent_states", str(time.monotonic_ns())), ) def defer_removal(self, file_path: str): From f415b4ab5771ac8b952270743b07348bf139c0f3 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 20 Aug 2026 18:39:40 -0400 Subject: [PATCH 15/15] Update model-metadata --- cecli/resources/model-metadata.json | 595 +++++++++++++++++++++++++--- 1 file changed, 535 insertions(+), 60 deletions(-) diff --git a/cecli/resources/model-metadata.json b/cecli/resources/model-metadata.json index f0f1988ff95..3e62cce8416 100644 --- a/cecli/resources/model-metadata.json +++ b/cecli/resources/model-metadata.json @@ -1641,6 +1641,7 @@ "supports_vision": true }, "azure/eu/gpt-5.1": { + "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.4e-7, "input_cost_per_token": 0.00000138, "litellm_provider": "azure", @@ -1710,6 +1711,7 @@ "supports_none_reasoning_effort": true }, "azure/eu/gpt-5.4": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.8e-7, "cache_read_input_token_cost_priority": 5.5e-7, "input_cost_per_token": 0.00000275, @@ -1787,6 +1789,7 @@ "supports_minimal_reasoning_effort": true }, "azure/eu/gpt-5.5": { + "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.0000011, "cache_read_input_token_cost_priority": 0.00000138, @@ -2165,6 +2168,7 @@ "supports_vision": true }, "azure/global-standard/gpt-4o-mini": { + "deprecation_date": "2027-04-14", "input_cost_per_token": 1.5e-7, "litellm_provider": "azure", "max_input_tokens": 128000, @@ -2213,6 +2217,7 @@ "supports_vision": true }, "azure/global/gpt-5.1": { + "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-7, "input_cost_per_token": 0.00000125, "litellm_provider": "azure", @@ -2466,6 +2471,7 @@ "supports_vision": true }, "azure/gpt-4.1": { + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000002, "input_cost_per_token_batches": 0.000001, @@ -2533,6 +2539,7 @@ "supports_web_search": false }, "azure/gpt-4.1-mini": { + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 4e-7, "input_cost_per_token_batches": 2e-7, @@ -2600,6 +2607,7 @@ "supports_web_search": false }, "azure/gpt-4.1-nano": { + "deprecation_date": "2026-10-14", "cache_read_input_token_cost": 2.5e-8, "input_cost_per_token": 1e-7, "input_cost_per_token_batches": 5e-8, @@ -2780,6 +2788,7 @@ "supports_vision": false }, "azure/gpt-4o-mini": { + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 7.5e-8, "input_cost_per_token": 1.65e-7, "litellm_provider": "azure", @@ -2844,6 +2853,7 @@ "supports_vision": false }, "azure/gpt-5": { + "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 1.25e-7, "input_cost_per_token": 0.00000125, "litellm_provider": "azure", @@ -2976,6 +2986,7 @@ "supports_vision": true }, "azure/gpt-5-mini": { + "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 2.5e-8, "input_cost_per_token": 2.5e-7, "litellm_provider": "azure", @@ -3041,6 +3052,7 @@ "supports_vision": true }, "azure/gpt-5-nano": { + "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 5e-9, "input_cost_per_token": 5e-8, "litellm_provider": "azure", @@ -3106,6 +3118,7 @@ "supports_vision": true }, "azure/gpt-5.1": { + "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-7, "input_cost_per_token": 0.00000125, "litellm_provider": "azure", @@ -3250,6 +3263,7 @@ "supports_none_reasoning_effort": true }, "azure/gpt-5.2": { + "deprecation_date": "2027-06-08", "cache_read_input_token_cost": 1.75e-7, "input_cost_per_token": 0.00000175, "litellm_provider": "azure", @@ -3425,6 +3439,7 @@ "supports_vision": true }, "azure/gpt-5.4": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.5e-7, "cache_read_input_token_cost_above_272k_tokens": 5e-7, "cache_read_input_token_cost_priority": 5e-7, @@ -3514,6 +3529,7 @@ "supports_minimal_reasoning_effort": true }, "azure/gpt-5.4-mini": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 7.5e-8, "input_cost_per_token": 7.5e-7, "litellm_provider": "azure", @@ -3595,6 +3611,7 @@ "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-nano": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 2e-8, "input_cost_per_token": 2e-7, "litellm_provider": "azure", @@ -3676,6 +3693,7 @@ "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.5": { + "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, "cache_read_input_token_cost_priority": 0.000001, @@ -4090,6 +4108,7 @@ "supports_function_calling": true }, "azure/o1": { + "deprecation_date": "2026-10-21", "cache_read_input_token_cost": 0.0000075, "input_cost_per_token": 0.000015, "litellm_provider": "azure", @@ -4184,6 +4203,7 @@ "supports_vision": false }, "azure/o3": { + "deprecation_date": "2026-10-21", "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000002, "litellm_provider": "azure", @@ -4281,6 +4301,7 @@ "supports_web_search": true }, "azure/o3-mini": { + "deprecation_date": "2026-10-01", "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "azure", @@ -4311,6 +4332,7 @@ "supports_vision": false }, "azure/o3-pro": { + "deprecation_date": "2026-12-17", "input_cost_per_token": 0.00002, "input_cost_per_token_batches": 0.00001, "litellm_provider": "azure", @@ -4372,6 +4394,7 @@ "supports_vision": true }, "azure/o4-mini": { + "deprecation_date": "2026-10-16", "cache_read_input_token_cost": 2.75e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "azure", @@ -4669,6 +4692,7 @@ "supports_vision": true }, "azure/us/gpt-5.1": { + "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.4e-7, "input_cost_per_token": 0.00000138, "litellm_provider": "azure", @@ -4738,6 +4762,7 @@ "supports_none_reasoning_effort": true }, "azure/us/gpt-5.4": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.8e-7, "cache_read_input_token_cost_priority": 5.5e-7, "input_cost_per_token": 0.00000275, @@ -4815,6 +4840,7 @@ "supports_minimal_reasoning_effort": true }, "azure/us/gpt-5.5": { + "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.0000011, "cache_read_input_token_cost_priority": 0.00000138, @@ -5208,6 +5234,7 @@ "supports_vision": true }, "azure_ai/FW-DeepSeek-V3.2": { + "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 3.1e-7, "input_cost_per_token": 6.2e-7, "litellm_provider": "azure_ai", @@ -5238,6 +5265,7 @@ "supports_tool_choice": true }, "azure_ai/FW-GLM-5": { + "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 2.2e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "azure_ai", @@ -5253,6 +5281,7 @@ "supports_tool_choice": true }, "azure_ai/FW-GLM-5.1": { + "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 2.86e-7, "input_cost_per_token": 0.00000154, "litellm_provider": "azure_ai", @@ -5319,6 +5348,7 @@ "supports_tool_choice": true }, "azure_ai/FW-Kimi-K2.5": { + "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 6.6e-7, "litellm_provider": "azure_ai", @@ -5411,6 +5441,7 @@ "supports_vision": true }, "azure_ai/FW-MiniMax-M2.5": { + "deprecation_date": "2027-07-01", "cache_read_input_token_cost": 3.3e-8, "input_cost_per_token": 3.3e-7, "litellm_provider": "azure_ai", @@ -5470,6 +5501,7 @@ "supports_tool_choice": true }, "azure_ai/Llama-3.2-11B-Vision-Instruct": { + "deprecation_date": "2026-06-13", "input_cost_per_token": 3.7e-7, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -5483,6 +5515,7 @@ "supports_vision": true }, "azure_ai/Llama-3.2-90B-Vision-Instruct": { + "deprecation_date": "2026-06-13", "input_cost_per_token": 0.00000204, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -5556,6 +5589,7 @@ "supports_tool_choice": true }, "azure_ai/Meta-Llama-3.1-405B-Instruct": { + "deprecation_date": "2026-06-13", "input_cost_per_token": 0.00000533, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -5578,6 +5612,7 @@ "supports_tool_choice": true }, "azure_ai/Meta-Llama-3.1-8B-Instruct": { + "deprecation_date": "2026-06-13", "input_cost_per_token": 3e-7, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -5790,6 +5825,7 @@ "supports_max_reasoning_effort": true }, "azure_ai/claude-haiku-4-5": { + "deprecation_date": "2026-10-19", "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, @@ -5811,6 +5847,7 @@ "supports_vision": true }, "azure_ai/claude-opus-4-1": { + "deprecation_date": "2026-08-05", "cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, @@ -5832,6 +5869,7 @@ "supports_vision": true }, "azure_ai/claude-opus-4-5": { + "deprecation_date": "2026-10-19", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -5854,6 +5892,7 @@ "supports_output_config": true }, "azure_ai/claude-opus-4-6": { + "deprecation_date": "2027-02-02", "supports_adaptive_thinking": true, "input_cost_per_token": 0.000005, "output_cost_per_token": 0.000025, @@ -5883,6 +5922,7 @@ "supports_max_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { + "deprecation_date": "2027-04-06", "supports_adaptive_thinking": true, "input_cost_per_token": 0.000005, "output_cost_per_token": 0.000025, @@ -5976,6 +6016,7 @@ "prompt_cache_min_tokens": 512 }, "azure_ai/claude-sonnet-4-5": { + "deprecation_date": "2026-10-19", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -5997,6 +6038,7 @@ "supports_vision": true }, "azure_ai/claude-sonnet-4-6": { + "deprecation_date": "2027-02-10", "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, @@ -6052,6 +6094,7 @@ "supports_max_reasoning_effort": true }, "azure_ai/deepseek-r1": { + "deprecation_date": "2026-08-13", "input_cost_per_token": 0.00000135, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -6075,6 +6118,7 @@ "supports_tool_choice": true }, "azure_ai/deepseek-v3-0324": { + "deprecation_date": "2026-07-13", "input_cost_per_token": 0.00000114, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -6087,6 +6131,7 @@ "supports_tool_choice": true }, "azure_ai/deepseek-v3.1": { + "deprecation_date": "2026-07-13", "input_cost_per_token": 0.00000123, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -6130,6 +6175,7 @@ "supports_tool_choice": true }, "azure_ai/deepseek-v4-flash": { + "deprecation_date": "2028-02-20", "input_cost_per_token": 1.9e-7, "litellm_provider": "azure_ai", "max_input_tokens": 1000000, @@ -6143,6 +6189,7 @@ "supports_tool_choice": true }, "azure_ai/deepseek-v4-pro": { + "deprecation_date": "2028-02-20", "input_cost_per_token": 0.00000174, "litellm_provider": "azure_ai", "max_input_tokens": 1000000, @@ -6156,6 +6203,7 @@ "supports_tool_choice": true }, "azure_ai/global/grok-3": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 0.000003, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -6170,6 +6218,7 @@ "supports_web_search": true }, "azure_ai/global/grok-3-mini": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 2.5e-7, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -6185,6 +6234,7 @@ "supports_web_search": true }, "azure_ai/gpt-5.4": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.5e-7, "cache_read_input_token_cost_above_272k_tokens": 5e-7, "cache_read_input_token_cost_priority": 5e-7, @@ -6231,6 +6281,7 @@ "supports_minimal_reasoning_effort": true }, "azure_ai/gpt-5.4-2026-03-05": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.5e-7, "cache_read_input_token_cost_above_272k_tokens": 5e-7, "cache_read_input_token_cost_priority": 5e-7, @@ -6277,6 +6328,7 @@ "supports_minimal_reasoning_effort": true }, "azure_ai/gpt-5.4-mini": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 7.5e-8, "cache_read_input_token_cost_priority": 1.5e-7, "input_cost_per_token": 7.5e-7, @@ -6317,6 +6369,7 @@ "supports_minimal_reasoning_effort": false }, "azure_ai/gpt-5.4-mini-2026-03-17": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 7.5e-8, "cache_read_input_token_cost_priority": 1.5e-7, "input_cost_per_token": 7.5e-7, @@ -6357,6 +6410,7 @@ "supports_minimal_reasoning_effort": false }, "azure_ai/gpt-5.4-nano": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 2e-8, "cache_read_input_token_cost_priority": 4e-8, "input_cost_per_token": 2e-7, @@ -6397,6 +6451,7 @@ "supports_minimal_reasoning_effort": false }, "azure_ai/gpt-5.4-nano-2026-03-17": { + "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 2e-8, "cache_read_input_token_cost_priority": 4e-8, "input_cost_per_token": 2e-7, @@ -6437,6 +6492,7 @@ "supports_minimal_reasoning_effort": false }, "azure_ai/gpt-5.5": { + "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, "cache_read_input_token_cost_priority": 0.000001, @@ -6543,6 +6599,7 @@ "supports_tool_choice": true }, "azure_ai/grok-3": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 0.000003, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -6557,6 +6614,7 @@ "supports_web_search": true }, "azure_ai/grok-3-mini": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 2.5e-7, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -6615,6 +6673,7 @@ "supports_web_search": true }, "azure_ai/grok-4-fast-non-reasoning": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 2e-7, "output_cost_per_token": 5e-7, "litellm_provider": "azure_ai", @@ -6628,6 +6687,7 @@ "supports_web_search": true }, "azure_ai/grok-4-fast-reasoning": { + "deprecation_date": "2026-05-01", "input_cost_per_token": 2e-7, "output_cost_per_token": 5e-7, "litellm_provider": "azure_ai", @@ -6694,6 +6754,7 @@ "supports_tool_choice": true }, "azure_ai/kimi-k2.5": { + "deprecation_date": "2027-01-26", "input_cost_per_token": 6e-7, "litellm_provider": "azure_ai", "max_input_tokens": 262144, @@ -6708,6 +6769,7 @@ "supports_vision": true }, "azure_ai/kimi-k2.6": { + "deprecation_date": "2027-04-16", "input_cost_per_token": 9.5e-7, "litellm_provider": "azure_ai", "max_input_tokens": 262144, @@ -8886,6 +8948,27 @@ "supports_vision": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, + "bedrock_mantle/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000066, + "cache_read_input_token_cost": 5.5e-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 + }, "cerebras/gpt-oss-120b": { "input_cost_per_token": 3.5e-7, "litellm_provider": "cerebras", @@ -9186,6 +9269,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-fable-5": { + "deprecation_date": "2027-06-09", "cache_creation_input_token_cost": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00002, "cache_read_input_token_cost": 0.000001, @@ -9202,6 +9286,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9221,6 +9306,7 @@ "prompt_cache_min_tokens": 512 }, "claude-haiku-4-5": { + "deprecation_date": "2026-10-15", "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, @@ -9244,6 +9330,7 @@ "prompt_cache_min_tokens": 4096 }, "claude-haiku-4-5-20251001": { + "deprecation_date": "2026-10-15", "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, @@ -9285,6 +9372,7 @@ }, "source": "https://docs.claude.com/en/docs/about-claude/models/overview", "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9419,6 +9507,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-opus-4-5": { + "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9448,6 +9537,7 @@ "prompt_cache_min_tokens": 4096 }, "claude-opus-4-5-20251101": { + "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9477,6 +9567,7 @@ "prompt_cache_min_tokens": 4096 }, "claude-opus-4-6": { + "deprecation_date": "2027-02-05", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9513,6 +9604,7 @@ "prompt_cache_min_tokens": 4096 }, "claude-opus-4-6-20260205": { + "deprecation_date": "2027-02-05", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9549,6 +9641,7 @@ "prompt_cache_min_tokens": 4096 }, "claude-opus-4-7": { + "deprecation_date": "2027-04-16", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9587,6 +9680,7 @@ "prompt_cache_min_tokens": 2048 }, "claude-opus-4-7-20260416": { + "deprecation_date": "2027-04-16", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9625,6 +9719,7 @@ "prompt_cache_min_tokens": 2048 }, "claude-opus-4-8": { + "deprecation_date": "2027-05-28", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9641,6 +9736,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9663,6 +9759,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-opus-5": { + "deprecation_date": "2027-07-24", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -9679,6 +9776,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9733,6 +9831,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-sonnet-4-5": { + "deprecation_date": "2026-09-29", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, @@ -9766,6 +9865,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-sonnet-4-5-20250929": { + "deprecation_date": "2026-09-29", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, @@ -9829,6 +9929,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-sonnet-4-6": { + "deprecation_date": "2027-02-17", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -9860,6 +9961,7 @@ "prompt_cache_min_tokens": 1024 }, "claude-sonnet-5": { + "deprecation_date": "2027-06-30", "cache_creation_input_token_cost": 0.0000025, "cache_creation_input_token_cost_above_1hr": 0.000004, "cache_read_input_token_cost": 2e-7, @@ -9876,6 +9978,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -11505,6 +11608,25 @@ "supports_tool_choice": true, "supports_output_config": true }, + "databricks/databricks-claude-opus-4-6": { + "input_cost_per_token": 0.00000500003, + "input_dbu_cost_per_token": 0.000071429, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "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.000025000010000000002, + "output_dbu_cost_per_token": 0.000357143, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "databricks/databricks-claude-sonnet-4": { "input_cost_per_token": 0.0000029999900000000002, "input_dbu_cost_per_token": 0.000042857, @@ -11562,6 +11684,25 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "databricks/databricks-claude-sonnet-4-6": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "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.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "databricks/databricks-gemini-2-5-flash": { "input_cost_per_token": 3.0001999999999996e-7, "input_dbu_cost_per_token": 0.000004285999999999999, @@ -11596,6 +11737,74 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "databricks/databricks-gemini-3-1-flash-lite": { + "input_cost_per_token": 3.1248e-7, + "input_dbu_cost_per_token": 0.000004464, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "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.00000187502, + "output_dbu_cost_per_token": 0.000026786, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemini-3-1-pro": { + "input_cost_per_token": 0.00000249998, + "input_dbu_cost_per_token": 0.000035714, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "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.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemini-3-flash": { + "input_cost_per_token": 6.2503e-7, + "input_dbu_cost_per_token": 0.000008929, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "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.00000374997, + "output_dbu_cost_per_token": 0.000053571, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemini-3-pro": { + "input_cost_per_token": 0.00000249998, + "input_dbu_cost_per_token": 0.000035714, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "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.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, "databricks/databricks-gemma-3-12b": { "input_cost_per_token": 1.5000999999999998e-7, "input_dbu_cost_per_token": 0.0000021429999999999996, @@ -11641,6 +11850,126 @@ "output_dbu_cost_per_token": 0.000142857, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" }, + "databricks/databricks-gpt-5-1-codex-max": { + "input_cost_per_token": 0.00000124999, + "input_dbu_cost_per_token": 0.000017857, + "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.000009999990000000002, + "output_dbu_cost_per_token": 0.000142857, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-1-codex-mini": { + "input_cost_per_token": 2.4997e-7, + "input_dbu_cost_per_token": 0.000003571, + "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.00000199997, + "output_dbu_cost_per_token": 0.000028571, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-2": { + "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" + }, + "databricks/databricks-gpt-5-2-codex": { + "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" + }, + "databricks/databricks-gpt-5-3-codex": { + "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" + }, + "databricks/databricks-gpt-5-4": { + "input_cost_per_token": 0.00000249998, + "input_dbu_cost_per_token": 0.000035714, + "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.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-4-mini": { + "input_cost_per_token": 7.4998e-7, + "input_dbu_cost_per_token": 0.000010714, + "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.00000450002, + "output_dbu_cost_per_token": 0.000064286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-4-nano": { + "input_cost_per_token": 1.9999e-7, + "input_dbu_cost_per_token": 0.000002857, + "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.00000124999, + "output_dbu_cost_per_token": 0.000017857, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, "databricks/databricks-gpt-5-mini": { "input_cost_per_token": 2.4997000000000006e-7, "input_dbu_cost_per_token": 0.000003571, @@ -12683,15 +13012,15 @@ }, "deepseek-v4-flash": { "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 2.8e-9, - "input_cost_per_token": 1.4e-7, - "input_cost_per_token_cache_hit": 2.8e-9, + "cache_read_input_token_cost": 1.4e-8, + "input_cost_per_token": 4.4e-7, + "input_cost_per_token_cache_hit": 1.4e-8, "litellm_provider": "deepseek", "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 2.8e-7, + "output_cost_per_token": 0.00000132, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -12709,15 +13038,15 @@ }, "deepseek-v4-pro": { "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 3.625e-9, - "input_cost_per_token": 4.35e-7, - "input_cost_per_token_cache_hit": 3.625e-9, + "cache_read_input_token_cost": 4.4e-8, + "input_cost_per_token": 0.00000132, + "input_cost_per_token_cache_hit": 4.4e-8, "litellm_provider": "deepseek", "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 8.7e-7, + "output_cost_per_token": 0.00000396, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -12870,15 +13199,15 @@ }, "deepseek/deepseek-v4-flash": { "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 2.8e-9, - "input_cost_per_token": 1.4e-7, - "input_cost_per_token_cache_hit": 2.8e-9, + "cache_read_input_token_cost": 1.4e-8, + "input_cost_per_token": 4.4e-7, + "input_cost_per_token_cache_hit": 1.4e-8, "litellm_provider": "deepseek", "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 2.8e-7, + "output_cost_per_token": 0.00000132, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -12896,15 +13225,15 @@ }, "deepseek/deepseek-v4-pro": { "cache_creation_input_token_cost": 0, - "cache_read_input_token_cost": 3.625e-9, - "input_cost_per_token": 4.35e-7, - "input_cost_per_token_cache_hit": 3.625e-9, + "cache_read_input_token_cost": 4.4e-8, + "input_cost_per_token": 0.00000132, + "input_cost_per_token_cache_hit": 4.4e-8, "litellm_provider": "deepseek", "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 8.7e-7, + "output_cost_per_token": 0.00000396, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -16779,6 +17108,7 @@ "supports_vision": true }, "gemini-2.5-flash": { + "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 3e-8, "input_cost_per_audio_token": 0.000001, "input_cost_per_token": 3e-7, @@ -16824,6 +17154,7 @@ "supports_image_size": false }, "gemini-2.5-flash-image": { + "deprecation_date": "2026-10-02", "cache_read_input_token_cost": 3e-8, "input_cost_per_audio_token": 0.000001, "input_cost_per_token": 3e-7, @@ -16868,6 +17199,7 @@ "supports_image_size": false }, "gemini-2.5-flash-lite": { + "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1e-8, "input_cost_per_audio_token": 3e-7, "input_cost_per_token": 1e-7, @@ -17124,6 +17456,7 @@ "supports_image_size": false }, "gemini-2.5-pro": { + "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1.25e-7, "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, @@ -17253,6 +17586,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3-pro-image": { + "deprecation_date": "2027-05-28", "input_cost_per_image": 0.0011, "input_cost_per_token": 0.000002, "input_cost_per_token_batches": 0.000001, @@ -17389,6 +17723,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image": { + "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-7, "litellm_provider": "vertex_ai-language-models", @@ -17465,6 +17800,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-lite": { + "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-8, "cache_read_input_token_cost_flex": 1.25e-8, "cache_read_input_token_cost_priority": 4.5e-8, @@ -17602,6 +17938,7 @@ "gemini_audio_only_live": true }, "gemini-3.1-pro-preview": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, @@ -17659,6 +17996,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, @@ -17710,6 +18048,8 @@ "web_search_billing_unit": "per_query" }, "gemini-3.5-flash": { + "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-7, "input_cost_per_audio_token": 0.000001, "input_cost_per_token": 0.0000015, @@ -17762,6 +18102,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.5-flash-lite": { + "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-8, "cache_read_input_token_cost_flex": 2e-8, "cache_read_input_token_cost_priority": 5e-8, @@ -17817,20 +18158,21 @@ "web_search_billing_unit": "per_query" }, "gemini-3.6-flash": { - "cache_read_input_token_cost": 1.5e-7, - "cache_read_input_token_cost_flex": 7.5e-8, - "input_cost_per_token": 0.0000015, - "input_cost_per_token_batches": 7.5e-7, - "input_cost_per_token_flex": 7.5e-7, + "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.0000075, - "output_cost_per_token": 0.0000075, - "output_cost_per_token_batches": 0.00000375, - "output_cost_per_token_flex": 0.00000375, + "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/pricing/gemini-3", "supported_endpoints": [ "/v1/chat/completions", @@ -17861,9 +18203,9 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "input_cost_per_token_priority": 0.0000027, - "output_cost_per_token_priority": 0.0000135, - "cache_read_input_token_cost_priority": 2.7e-7, + "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, @@ -17872,6 +18214,7 @@ "web_search_billing_unit": "per_query" }, "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, "input_cost_per_token": 7.5e-7, @@ -19048,8 +19391,8 @@ "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-flash-image": { - "input_cost_per_token": 2.5e-7, - "input_cost_per_token_batches": 1.25e-7, + "input_cost_per_token": 5e-7, + "input_cost_per_token_batches": 2.5e-7, "litellm_provider": "gemini", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -19057,8 +19400,8 @@ "mode": "image_generation", "output_cost_per_image": 0.045, "output_cost_per_image_token": 0.00006, - "output_cost_per_token": 0.0000015, - "output_cost_per_token_batches": 7.5e-7, + "output_cost_per_token": 0.000003, + "output_cost_per_token_batches": 0.0000015, "rpm": 1000, "tpm": 4000000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image", @@ -19091,8 +19434,8 @@ }, "gemini/gemini-3.1-flash-image-preview": { "deprecation_date": "2026-06-25", - "input_cost_per_token": 2.5e-7, - "input_cost_per_token_batches": 1.25e-7, + "input_cost_per_token": 5e-7, + "input_cost_per_token_batches": 2.5e-7, "litellm_provider": "gemini", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -19100,8 +19443,8 @@ "mode": "image_generation", "output_cost_per_image": 0.045, "output_cost_per_image_token": 0.00006, - "output_cost_per_token": 0.0000015, - "output_cost_per_token_batches": 7.5e-7, + "output_cost_per_token": 0.000003, + "output_cost_per_token_batches": 0.0000015, "rpm": 1000, "tpm": 4000000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview", @@ -19278,6 +19621,7 @@ "gemini_audio_only_live": true }, "gemini/gemini-3.1-pro-preview": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "input_cost_per_token": 0.000002, @@ -19335,6 +19679,7 @@ "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-pro-preview-customtools": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "input_cost_per_token": 0.000002, @@ -19392,6 +19737,7 @@ "web_search_billing_unit": "per_query" }, "gemini/gemini-3.5-flash": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 1.5e-7, "input_cost_per_audio_token": 0.000001, "input_cost_per_token": 0.0000015, @@ -19503,20 +19849,21 @@ "web_search_billing_unit": "per_query" }, "gemini/gemini-3.6-flash": { - "cache_read_input_token_cost": 1.5e-7, - "cache_read_input_token_cost_flex": 7.5e-8, - "input_cost_per_token": 0.0000015, - "input_cost_per_token_batches": 7.5e-7, - "input_cost_per_token_flex": 7.5e-7, + "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_reasoning_token": 0.0000075, - "output_cost_per_token": 0.0000075, - "output_cost_per_token_batches": 0.00000375, - "output_cost_per_token_flex": 0.00000375, + "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/pricing/gemini-3", "supported_endpoints": [ @@ -19549,9 +19896,9 @@ "supports_web_search": true, "supports_native_streaming": true, "tpm": 800000, - "input_cost_per_token_priority": 0.0000027, - "output_cost_per_token_priority": 0.0000135, - "cache_read_input_token_cost_priority": 2.7e-7, + "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, @@ -19560,6 +19907,7 @@ "web_search_billing_unit": "per_query" }, "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, "input_cost_per_token": 7.5e-7, @@ -19906,6 +20254,7 @@ } }, "gemini/gemini-robotics-er-1.6-preview": { + "deprecation_date": "2026-08-31", "input_cost_per_audio_token": 0.000002, "input_cost_per_token": 0.000001, "litellm_provider": "gemini", @@ -20881,6 +21230,21 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "global.xai.grok-4.6": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 5e-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": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "gmi/MiniMaxAI/MiniMax-M2.1": { "input_cost_per_token": 3e-7, "litellm_provider": "gmi", @@ -23015,6 +23379,7 @@ "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, @@ -23077,6 +23442,7 @@ "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, @@ -23139,6 +23505,7 @@ "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, @@ -23201,6 +23568,7 @@ "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, @@ -25889,6 +26257,23 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "mistral/glm-5-2": { + "cache_read_input_token_cost": 1.4e-7, + "input_cost_per_token": 0.0000014, + "litellm_provider": "mistral", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "source": "https://docs.mistral.ai/models/zai-glm-5-2", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "mistral/labs-devstral-small-2512": { "deprecation_date": "2026-03-31", "input_cost_per_token": 1e-7, @@ -26504,6 +26889,23 @@ "supports_tool_choice": true, "supports_vision": true }, + "mistral/zai-glm-5-2": { + "cache_read_input_token_cost": 1.4e-7, + "input_cost_per_token": 0.0000014, + "litellm_provider": "mistral", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "source": "https://docs.mistral.ai/models/zai-glm-5-2", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "moonshot.kimi-k2-thinking": { "input_cost_per_token": 6e-7, "litellm_provider": "bedrock_converse", @@ -34970,6 +35372,21 @@ "supports_function_calling": true, "supports_pdf_input": true }, + "us.xai.grok-4.6": { + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000066, + "cache_read_input_token_cost": 5.5e-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": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "v0/v0-1.0-md": { "input_cost_per_token": 0.000003, "litellm_provider": "v0", @@ -36345,6 +36762,8 @@ "supports_vision": true }, "vertex_ai/claude-fable-5": { + "deprecation_date": "2027-06-08", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00002, @@ -36376,6 +36795,8 @@ "supports_max_reasoning_effort": true }, "vertex_ai/claude-fable-5@default": { + "deprecation_date": "2027-06-08", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00002, @@ -36407,6 +36828,7 @@ "supports_max_reasoning_effort": true }, "vertex_ai/claude-haiku-4-5": { + "deprecation_date": "2026-10-15", "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, @@ -36417,6 +36839,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.000005, + "regional_endpoint_uplift_multiplier": 1.1, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5", "supports_assistant_prefill": true, "supports_function_calling": true, @@ -36430,6 +36853,7 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-haiku-4-5@20251001": { + "deprecation_date": "2026-10-15", "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, @@ -36440,6 +36864,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.000005, + "regional_endpoint_uplift_multiplier": 1.1, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5", "supports_assistant_prefill": true, "supports_function_calling": true, @@ -36453,6 +36878,7 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-opus-4": { + "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, @@ -36480,6 +36906,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-opus-4-1": { + "deprecation_date": "2026-08-05", "cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, @@ -36498,6 +36925,7 @@ "supports_vision": true }, "vertex_ai/claude-opus-4-1@20250805": { + "deprecation_date": "2026-08-05", "cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, @@ -36516,6 +36944,7 @@ "supports_vision": true }, "vertex_ai/claude-opus-4-5": { + "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -36526,6 +36955,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000025, + "regional_endpoint_uplift_multiplier": 1.1, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -36544,6 +36974,7 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-opus-4-5@20251101": { + "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, @@ -36554,6 +36985,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000025, + "regional_endpoint_uplift_multiplier": 1.1, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -36573,6 +37005,8 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-opus-4-6": { + "deprecation_date": "2027-02-05", + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, @@ -36603,6 +37037,8 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-opus-4-6@default": { + "deprecation_date": "2027-02-05", + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, @@ -36633,6 +37069,8 @@ "prompt_cache_min_tokens": 4096 }, "vertex_ai/claude-opus-4-7": { + "deprecation_date": "2027-04-16", + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, @@ -36664,6 +37102,8 @@ "prompt_cache_min_tokens": 2048 }, "vertex_ai/claude-opus-4-7@default": { + "deprecation_date": "2027-04-16", + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, @@ -36695,6 +37135,8 @@ "prompt_cache_min_tokens": 2048 }, "vertex_ai/claude-opus-4-8": { + "deprecation_date": "2027-05-28", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, @@ -36727,6 +37169,8 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-opus-4-8@default": { + "deprecation_date": "2027-05-28", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, @@ -36759,6 +37203,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-opus-4@20250514": { + "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, @@ -36786,6 +37231,8 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-opus-5": { + "deprecation_date": "2027-01-24", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, @@ -36818,6 +37265,8 @@ "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-opus-5@default": { + "deprecation_date": "2027-01-24", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000625, @@ -36850,6 +37299,7 @@ "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-sonnet-4": { + "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -36881,6 +37331,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-4-5": { + "deprecation_date": "2026-09-29", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -36897,6 +37348,7 @@ "mode": "chat", "output_cost_per_token": 0.000015, "output_cost_per_token_batches": 0.0000075, + "regional_endpoint_uplift_multiplier": 1.1, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -36909,6 +37361,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-4-5@20250929": { + "deprecation_date": "2026-09-29", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -36925,6 +37378,7 @@ "mode": "chat", "output_cost_per_token": 0.000015, "output_cost_per_token_batches": 0.0000075, + "regional_endpoint_uplift_multiplier": 1.1, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -36938,6 +37392,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-4-6": { + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, @@ -36968,6 +37423,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-4-6@default": { + "regional_endpoint_uplift_multiplier": 1.1, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, @@ -36998,6 +37454,7 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-4@20250514": { + "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, @@ -37029,6 +37486,8 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-5": { + "deprecation_date": "2026-12-24", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 0.0000025, "cache_creation_input_token_cost_above_1hr": 0.000004, @@ -37061,6 +37520,8 @@ "prompt_cache_min_tokens": 1024 }, "vertex_ai/claude-sonnet-5@default": { + "deprecation_date": "2026-12-24", + "regional_endpoint_uplift_multiplier": 1.1, "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 0.0000025, "cache_creation_input_token_cost_above_1hr": 0.000004, @@ -37204,6 +37665,7 @@ "supports_tool_choice": true }, "vertex_ai/gemini-2.5-flash-image": { + "deprecation_date": "2026-10-02", "cache_read_input_token_cost": 3e-8, "input_cost_per_audio_token": 0.000001, "input_cost_per_token": 3e-7, @@ -37352,6 +37814,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-flash-lite": { + "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-8, "cache_read_input_token_cost_flex": 1.25e-8, "cache_read_input_token_cost_priority": 4.5e-8, @@ -37370,6 +37833,7 @@ "output_cost_per_token_batches": 7.5e-7, "output_cost_per_token_flex": 7.5e-7, "output_cost_per_token_priority": 0.0000027, + "regional_endpoint_uplift_multiplier": 1.1, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", "supported_endpoints": [ "/v1/chat/completions", @@ -37456,6 +37920,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, @@ -37513,6 +37978,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview-customtools": { + "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-7, "cache_read_input_token_cost_above_200k_tokens": 4e-7, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, @@ -37570,6 +38036,8 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash": { + "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-7, "input_cost_per_token": 0.0000015, "input_cost_per_audio_token": 0.000001, @@ -37580,6 +38048,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 0.000009, "output_cost_per_token": 0.000009, + "regional_endpoint_uplift_multiplier": 1.1, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -37621,6 +38090,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash-lite": { + "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-8, "cache_read_input_token_cost_flex": 2e-8, "cache_read_input_token_cost_priority": 5e-8, @@ -37638,6 +38108,7 @@ "output_cost_per_token_batches": 0.00000125, "output_cost_per_token_flex": 0.00000125, "output_cost_per_token_priority": 0.0000045, + "regional_endpoint_uplift_multiplier": 1.1, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", "supported_endpoints": [ "/v1/chat/completions", @@ -37676,20 +38147,22 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.6-flash": { - "cache_read_input_token_cost": 1.5e-7, - "cache_read_input_token_cost_flex": 7.5e-8, - "input_cost_per_token": 0.0000015, - "input_cost_per_token_batches": 7.5e-7, - "input_cost_per_token_flex": 7.5e-7, + "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.0000075, - "output_cost_per_token": 0.0000075, - "output_cost_per_token_batches": 0.00000375, - "output_cost_per_token_flex": 0.00000375, + "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", @@ -37719,9 +38192,9 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "input_cost_per_token_priority": 0.0000027, - "output_cost_per_token_priority": 0.0000135, - "cache_read_input_token_cost_priority": 2.7e-7, + "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, @@ -37730,6 +38203,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/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, "input_cost_per_token": 7.5e-7, @@ -37744,6 +38218,7 @@ "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",