diff --git a/plugins/model-providers/commandcode/__init__.py b/plugins/model-providers/commandcode/__init__.py new file mode 100644 index 000000000000..2b6cebad3a15 --- /dev/null +++ b/plugins/model-providers/commandcode/__init__.py @@ -0,0 +1,5 @@ +"""CommandCode provider plugin.""" + +from .provider import commandcode, commandcode_anthropic + +__all__ = ["commandcode", "commandcode_anthropic"] diff --git a/plugins/model-providers/commandcode/anthropic_shim.py b/plugins/model-providers/commandcode/anthropic_shim.py new file mode 100644 index 000000000000..05f4824b470a --- /dev/null +++ b/plugins/model-providers/commandcode/anthropic_shim.py @@ -0,0 +1,319 @@ +"""Anthropic Messages API shim for CommandCode. + +CommandCode exposes a public OpenAI-compatible catalog at +``https://api.commandcode.ai/provider/v1/models`` and a bearer-authenticated +Anthropic-compatible ``/v1/messages`` route under the same provider root. +Hermes' native Anthropic transport is built around providers with either +Anthropic's native auth semantics or hardcoded third-party compatibility +rules, so this module provides two building blocks: + +- ``build_commandcode_anthropic_profile``: a provider profile factory for the + ``commandcode-anthropic`` profile. +- ``CommandCodeAnthropicShim``: a tiny adapter that presents an + ``Anthropic.messages.create(...)``-like surface over an OpenAI-compatible + ``chat.completions`` client. + +The shim keeps the implementation self-contained so future runtime wiring can +opt into it without duplicating message / tool conversion logic. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +from providers.base import ProviderProfile + +COMMANDCODE_ANTHROPIC_BASE_URL = "https://api.commandcode.ai/provider" + + +def _coerce_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, dict): + if isinstance(value.get("text"), str): + return value["text"] + if isinstance(value.get("content"), str): + return value["content"] + return str(value) + + +def _flatten_system_text(system: Any) -> str: + if isinstance(system, str): + return system + if isinstance(system, list): + parts: list[str] = [] + for block in system: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + text = _coerce_text(block.get("text")) + if text: + parts.append(text) + return "\n".join(parts) + return "" + + +def _tool_result_to_openai_message(block: dict[str, Any]) -> dict[str, Any]: + payload = block.get("content") + if isinstance(payload, list): + payload = "\n".join( + _coerce_text(item.get("text")) + for item in payload + if isinstance(item, dict) and item.get("type") == "text" + ) + elif isinstance(payload, dict): + payload = payload.get("text") or json.dumps(payload) + elif not isinstance(payload, str): + payload = json.dumps(payload if payload is not None else "") + return { + "role": "tool", + "tool_call_id": str(block.get("tool_use_id") or block.get("id") or "tool"), + "content": str(payload or ""), + } + + +def _assistant_block_to_tool_call(block: dict[str, Any], index: int) -> dict[str, Any]: + arguments = block.get("input") + if not isinstance(arguments, str): + arguments = json.dumps(arguments or {}) + return { + "id": str(block.get("id") or f"toolu_{index}"), + "type": "function", + "function": { + "name": str(block.get("name") or f"tool_{index}"), + "arguments": arguments, + }, + } + + +def anthropic_messages_to_openai( + *, + messages: list[dict[str, Any]], + system: Any = None, +) -> list[dict[str, Any]]: + """Convert Anthropic-style messages blocks to OpenAI chat messages.""" + converted: list[dict[str, Any]] = [] + system_text = _flatten_system_text(system) + if system_text: + converted.append({"role": "system", "content": system_text}) + + for message in messages or []: + if not isinstance(message, dict): + continue + role = str(message.get("role") or "user") + content = message.get("content") + + if isinstance(content, str): + converted.append({"role": role, "content": content}) + continue + + if not isinstance(content, list): + converted.append({"role": role, "content": _coerce_text(content)}) + continue + + text_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + tool_messages: list[dict[str, Any]] = [] + + for index, block in enumerate(content): + if not isinstance(block, dict): + continue + block_type = str(block.get("type") or "text") + if block_type == "text": + text = _coerce_text(block.get("text")) + if text: + text_parts.append(text) + elif block_type == "tool_use" and role == "assistant": + tool_calls.append(_assistant_block_to_tool_call(block, index)) + elif block_type == "tool_result" and role == "user": + tool_messages.append(_tool_result_to_openai_message(block)) + elif block_type == "thinking": + # OpenAI chat.completions has no first-class thinking block input. + continue + + if role == "assistant" and tool_calls: + assistant_message: dict[str, Any] = { + "role": "assistant", + "tool_calls": tool_calls, + } + if text_parts: + assistant_message["content"] = "\n".join(text_parts) + converted.append(assistant_message) + elif text_parts or role != "assistant": + converted.append({"role": role, "content": "\n".join(text_parts)}) + + converted.extend(tool_messages) + + return converted + + +def anthropic_tools_to_openai(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: + if not tools: + return None + converted: list[dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + converted.append( + { + "type": "function", + "function": { + "name": str(tool.get("name") or "tool"), + "description": str(tool.get("description") or ""), + "parameters": tool.get("input_schema") or {"type": "object", "properties": {}}, + }, + } + ) + return converted or None + + +def anthropic_tool_choice_to_openai(tool_choice: Any) -> Any: + if tool_choice in (None, "auto", "none"): + return tool_choice + if tool_choice in ("any", "required"): + return "required" + if isinstance(tool_choice, str): + return {"type": "function", "function": {"name": tool_choice}} + if isinstance(tool_choice, dict): + choice_type = str(tool_choice.get("type") or "").lower() + if choice_type in {"auto", "none"}: + return choice_type + if choice_type in {"any", "required"}: + return "required" + if choice_type == "tool": + return { + "type": "function", + "function": {"name": str(tool_choice.get("name") or "tool")}, + } + return None + + +def openai_response_to_anthropic(response: Any) -> Any: + """Convert a chat.completions response object to an Anthropic-like message.""" + choice = (getattr(response, "choices", None) or [None])[0] + message = getattr(choice, "message", None) + content: list[Any] = [] + + if message is not None: + message_content = getattr(message, "content", None) + if isinstance(message_content, str) and message_content: + content.append(SimpleNamespace(type="text", text=message_content)) + + for index, tool_call in enumerate(getattr(message, "tool_calls", None) or []): + function = getattr(tool_call, "function", None) + raw_arguments = getattr(function, "arguments", "{}") if function is not None else "{}" + try: + parsed_arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments + except Exception: + parsed_arguments = {} + content.append( + SimpleNamespace( + type="tool_use", + id=str(getattr(tool_call, "id", None) or f"toolu_{index}"), + name=str(getattr(function, "name", None) or f"tool_{index}"), + input=parsed_arguments or {}, + ) + ) + + finish_reason = str(getattr(choice, "finish_reason", "stop") or "stop") + stop_reason_map = { + "stop": "end_turn", + "tool_calls": "tool_use", + "function_call": "tool_use", + "length": "max_tokens", + "content_filter": "refusal", + } + usage = getattr(response, "usage", None) + anthropic_usage = None + if usage is not None: + anthropic_usage = SimpleNamespace( + input_tokens=getattr(usage, "prompt_tokens", 0) or 0, + output_tokens=getattr(usage, "completion_tokens", 0) or 0, + total_tokens=getattr(usage, "total_tokens", 0) + or ((getattr(usage, "prompt_tokens", 0) or 0) + (getattr(usage, "completion_tokens", 0) or 0)), + ) + + return SimpleNamespace( + id=str(getattr(response, "id", "") or ""), + model=getattr(response, "model", None), + content=content, + stop_reason=stop_reason_map.get(finish_reason, "end_turn"), + usage=anthropic_usage, + raw=response, + ) + + +class CommandCodeAnthropicShim: + """Expose ``messages.create()`` over an OpenAI-compatible client. + + The shim intentionally supports the subset Hermes relies on today: + system prompts, text messages, tool definitions, tool-choice routing, and + assistant tool calls. Thinking blocks are ignored on input because the + chat.completions wire format has no equivalent first-class field. + """ + + def __init__(self, openai_client: Any, default_model: str | None = None): + self._client = openai_client + self._default_model = default_model + self.messages = self + + def create(self, **kwargs: Any) -> Any: + model = kwargs.get("model") or self._default_model + if not model: + raise ValueError("CommandCodeAnthropicShim requires a model") + + response = self._client.chat.completions.create( + model=model, + messages=anthropic_messages_to_openai( + messages=kwargs.get("messages") or [], + system=kwargs.get("system"), + ), + tools=anthropic_tools_to_openai(kwargs.get("tools")), + tool_choice=anthropic_tool_choice_to_openai(kwargs.get("tool_choice")), + max_tokens=kwargs.get("max_tokens"), + temperature=kwargs.get("temperature"), + ) + return openai_response_to_anthropic(response) + + +def build_commandcode_anthropic_profile( + *, + env_vars: tuple[str, ...], + fallback_models: tuple[str, ...], + models_url: str, +) -> ProviderProfile: + """Build the ``commandcode-anthropic`` provider profile. + + ``base_url`` intentionally omits ``/v1`` because the Anthropic SDK appends + ``/v1/messages``. The public model catalog remains the OpenAI-compatible + ``/provider/v1/models`` endpoint. + """ + return ProviderProfile( + name="commandcode-anthropic", + aliases=("commandcode_claude", "commandcode-anthropic-messages"), + api_mode="anthropic_messages", + env_vars=env_vars, + display_name="CommandCode (Anthropic Messages)", + description="CommandCode bearer-auth Anthropic Messages compatibility route", + signup_url="https://commandcode.ai/", + base_url=COMMANDCODE_ANTHROPIC_BASE_URL, + models_url=models_url, + auth_type="api_key", + fallback_models=fallback_models, + default_aux_model="claude-haiku-4-5-20251001", + ) + + +__all__ = [ + "COMMANDCODE_ANTHROPIC_BASE_URL", + "CommandCodeAnthropicShim", + "anthropic_messages_to_openai", + "anthropic_tools_to_openai", + "anthropic_tool_choice_to_openai", + "openai_response_to_anthropic", + "build_commandcode_anthropic_profile", +] diff --git a/plugins/model-providers/commandcode/plugin.yaml b/plugins/model-providers/commandcode/plugin.yaml new file mode 100644 index 000000000000..a9d3a254eb52 --- /dev/null +++ b/plugins/model-providers/commandcode/plugin.yaml @@ -0,0 +1,5 @@ +name: commandcode-provider +kind: model-provider +version: 1.0.0 +description: CommandCode unified model provider +author: Nous Research diff --git a/plugins/model-providers/commandcode/provider.py b/plugins/model-providers/commandcode/provider.py new file mode 100644 index 000000000000..639d7ef88dd4 --- /dev/null +++ b/plugins/model-providers/commandcode/provider.py @@ -0,0 +1,219 @@ +"""CommandCode provider profiles. + +CommandCode exposes an OpenAI-compatible endpoint at +``https://api.commandcode.ai/provider/v1`` with a public ``/models`` catalog +that includes ``context_length`` metadata. We keep a static fallback table for +Hermes' offline / CI paths and opportunistically refresh it from the live +catalog when available. +""" + +from __future__ import annotations + +import json +import logging +import urllib.request +from typing import Any + +from providers import register_provider +from providers.base import ProviderProfile, _profile_user_agent + +from .anthropic_shim import build_commandcode_anthropic_profile + +logger = logging.getLogger(__name__) + +_COMMANDCODE_BASE_URL = "https://api.commandcode.ai/provider/v1" +_COMMANDCODE_MODELS_URL = f"{_COMMANDCODE_BASE_URL}/models" +_COMMANDCODE_ENV_VARS = ("COMMANDCODE_API_KEY",) + +# Snapshot taken from CommandCode's public /models catalog (2026-05-28). +# Keep the list intentionally broad so offline users still get the provider's +# main OSS / coding-friendly catalog in the picker. +_STATIC_CONTEXT_LENGTH_OVERRIDES: dict[str, int] = { + "deepseek/deepseek-v4-pro": 1_000_000, + "deepseek/deepseek-v4-flash": 1_000_000, + "Qwen/Qwen3.7-Max": 1_000_000, + "Qwen/Qwen3.6-Plus": 200_000, + "Qwen/Qwen3.6-Max-Preview": 200_000, + "moonshotai/Kimi-K2.6": 256_000, + "moonshotai/Kimi-K2.5": 256_000, + "zai-org/GLM-5.1": 200_000, + "zai-org/GLM-5": 200_000, + "MiniMaxAI/MiniMax-M2.7": 200_000, + "MiniMaxAI/MiniMax-M2.5": 200_000, + "stepfun/Step-3.5-Flash": 1_000_000, + "xiaomi/mimo-v2.5-pro": 1_000_000, + "xiaomi/mimo-v2.5": 1_000_000, + "google/gemini-3.5-flash": 1_000_000, + "google/gemini-3.1-flash-lite": 1_000_000, + "claude-sonnet-4-6": 1_000_000, + "claude-opus-4-7": 1_000_000, + "claude-haiku-4-5-20251001": 200_000, + "gpt-5.5": 200_000, + "gpt-5.4": 400_000, + "gpt-5.4-mini": 400_000, + "gpt-5.3-codex": 400_000, +} +_FALLBACK_MODELS: tuple[str, ...] = tuple(_STATIC_CONTEXT_LENGTH_OVERRIDES.keys()) + +_MODEL_CACHE: list[str] | None = None +_LIVE_CONTEXT_LENGTH_OVERRIDES: dict[str, int] = {} + + +def _normalize_model_id(model: str | None) -> str: + value = str(model or "").strip() + if not value: + return "" + + if ":" in value: + prefix, suffix = value.split(":", 1) + if prefix.strip().lower() in { + "commandcode", + "command-code", + "commandcode-anthropic", + }: + value = suffix.strip() + + lowered = value.lower() + for prefix in ("commandcode/", "command-code/"): + if lowered.startswith(prefix): + value = value[len(prefix):] + break + return value.strip() + + +def _casefold_lookup(mapping: dict[str, int]) -> dict[str, tuple[str, int]]: + return {key.lower(): (key, value) for key, value in mapping.items()} + + +class CommandCodeProfile(ProviderProfile): + """OpenAI-compatible CommandCode provider with live context metadata.""" + + @property + def context_length_overrides(self) -> dict[str, int]: + merged = dict(_STATIC_CONTEXT_LENGTH_OVERRIDES) + merged.update(_LIVE_CONTEXT_LENGTH_OVERRIDES) + return merged + + def resolve_model_id(self, model: str | None) -> str | None: + normalized = _normalize_model_id(model) + if not normalized: + return None + + by_lower = _casefold_lookup(self.context_length_overrides) + direct = by_lower.get(normalized.lower()) + if direct is not None: + return direct[0] + + bare = normalized.rsplit("/", 1)[-1].lower() + suffix_matches = [ + canonical + for lowered, (canonical, _ctx) in by_lower.items() + if lowered.rsplit("/", 1)[-1] == bare + ] + if len(suffix_matches) == 1: + return suffix_matches[0] + return normalized + + def get_context_length(self, model: str | None) -> int | None: + resolved = self.resolve_model_id(model) + if not resolved: + return None + entry = _casefold_lookup(self.context_length_overrides).get(resolved.lower()) + return entry[1] if entry is not None else None + + def fetch_models( + self, + *, + api_key: str | None = None, + timeout: float = 8.0, + ) -> list[str] | None: + """Fetch the public CommandCode catalog and cache context lengths.""" + global _MODEL_CACHE, _LIVE_CONTEXT_LENGTH_OVERRIDES # noqa: PLW0603 + if _MODEL_CACHE is not None: + return list(_MODEL_CACHE) + + request = urllib.request.Request(self.models_url or _COMMANDCODE_MODELS_URL) + request.add_header("Accept", "application/json") + request.add_header("User-Agent", _profile_user_agent()) + if api_key: + request.add_header("Authorization", f"Bearer {api_key}") + for header_name, header_value in self.default_headers.items(): + request.add_header(header_name, header_value) + + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode()) + except Exception as exc: + logger.debug("fetch_models(commandcode): %s", exc) + return None + + items = payload if isinstance(payload, list) else payload.get("data", []) + models: list[str] = [] + live_overrides: dict[str, int] = {} + for item in items: + if not isinstance(item, dict): + continue + model_id = str(item.get("id") or "").strip() + if not model_id: + continue + models.append(model_id) + context_length = item.get("context_length") + if isinstance(context_length, int) and context_length > 0: + live_overrides[model_id] = context_length + + if not models: + return None + + _MODEL_CACHE = models + if live_overrides: + _LIVE_CONTEXT_LENGTH_OVERRIDES = live_overrides + return list(_MODEL_CACHE) + + def build_api_kwargs_extras( + self, + *, + reasoning_config: dict | None = None, + supports_reasoning: bool = False, + **context: Any, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Pass OpenAI-style reasoning config through for supported models. + + CommandCode speaks a vanilla OpenAI-compatible wire format, so the + safest provider-level behavior is the same as OpenRouter's generic + ``extra_body.reasoning`` passthrough. + """ + if not supports_reasoning: + return {}, {} + if reasoning_config is None: + return {"reasoning": {"enabled": True, "effort": "medium"}}, {} + return {"reasoning": dict(reasoning_config)}, {} + + +commandcode = CommandCodeProfile( + name="commandcode", + aliases=("command-code", "ccode"), + env_vars=_COMMANDCODE_ENV_VARS, + display_name="CommandCode", + description="CommandCode — OpenAI-compatible unified model gateway", + signup_url="https://commandcode.ai/", + base_url=_COMMANDCODE_BASE_URL, + models_url=_COMMANDCODE_MODELS_URL, + auth_type="api_key", + fallback_models=_FALLBACK_MODELS, + default_aux_model="Qwen/Qwen3.6-Plus", +) + +commandcode_anthropic = build_commandcode_anthropic_profile( + env_vars=_COMMANDCODE_ENV_VARS, + fallback_models=_FALLBACK_MODELS, + models_url=_COMMANDCODE_MODELS_URL, +) + +register_provider(commandcode) +register_provider(commandcode_anthropic) + +__all__ = [ + "CommandCodeProfile", + "commandcode", + "commandcode_anthropic", +] diff --git a/tests/providers/test_commandcode_profile.py b/tests/providers/test_commandcode_profile.py new file mode 100644 index 000000000000..42c21d410112 --- /dev/null +++ b/tests/providers/test_commandcode_profile.py @@ -0,0 +1,175 @@ +"""Unit tests for the CommandCode model provider plugin.""" + +from __future__ import annotations + +import importlib +import json +from types import SimpleNamespace +from typing import Any + +import pytest + + +@pytest.fixture +def commandcode_module(): + import model_tools # noqa: F401 # triggers bundled provider discovery + + return importlib.import_module("plugins.model-providers.commandcode.provider") + + +@pytest.fixture +def commandcode_profile(commandcode_module): + import providers + + profile = providers.get_provider_profile("commandcode") + assert profile is not None, "commandcode provider profile must be registered" + return profile + + +@pytest.fixture +def commandcode_anthropic_profile(commandcode_module): + import providers + + profile = providers.get_provider_profile("commandcode-anthropic") + assert profile is not None, "commandcode-anthropic profile must be registered" + return profile + + +class TestCommandCodeProfile: + def test_profile_metadata(self, commandcode_profile): + assert commandcode_profile.name == "commandcode" + assert commandcode_profile.base_url == "https://api.commandcode.ai/provider/v1" + assert commandcode_profile.models_url == "https://api.commandcode.ai/provider/v1/models" + assert commandcode_profile.default_aux_model == "Qwen/Qwen3.6-Plus" + + def test_context_length_overrides_cover_catalog_snapshot(self, commandcode_profile): + overrides = commandcode_profile.context_length_overrides + assert len(overrides) >= 20 + assert overrides["deepseek/deepseek-v4-pro"] == 1_000_000 + assert overrides["deepseek/deepseek-v4-flash"] == 1_000_000 + assert overrides["Qwen/Qwen3.6-Plus"] == 200_000 + + @pytest.mark.parametrize( + ("requested", "resolved", "context_length"), + [ + ("deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro", 1_000_000), + ("deepseek-v4-pro", "deepseek/deepseek-v4-pro", 1_000_000), + ("commandcode:deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro", 1_000_000), + ("Qwen3.6-Plus", "Qwen/Qwen3.6-Plus", 200_000), + ], + ) + def test_known_models_resolve_from_context_overrides( + self, + commandcode_profile, + requested, + resolved, + context_length, + ): + assert commandcode_profile.resolve_model_id(requested) == resolved + assert commandcode_profile.get_context_length(requested) == context_length + + def test_reasoning_config_passes_through_like_openai_compat(self, commandcode_profile): + extra_body, top_level = commandcode_profile.build_api_kwargs_extras( + supports_reasoning=True, + reasoning_config={"enabled": True, "effort": "high"}, + ) + assert extra_body == {"reasoning": {"enabled": True, "effort": "high"}} + assert top_level == {} + + def test_fetch_models_updates_live_context_length_cache(self, commandcode_profile, commandcode_module, monkeypatch): + monkeypatch.setattr(commandcode_module, "_MODEL_CACHE", None) + monkeypatch.setattr(commandcode_module, "_LIVE_CONTEXT_LENGTH_OVERRIDES", {}) + + payload = { + "data": [ + { + "id": "deepseek/deepseek-v4-pro", + "context_length": 1_000_000, + }, + { + "id": "org/new-model", + "context_length": 262_144, + }, + ] + } + + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(payload).encode() + + def fake_urlopen(request, timeout): + assert request.full_url == commandcode_profile.models_url + assert timeout == 8.0 + return _FakeResponse() + + monkeypatch.setattr(commandcode_module.urllib.request, "urlopen", fake_urlopen) + + assert commandcode_profile.fetch_models() == [ + "deepseek/deepseek-v4-pro", + "org/new-model", + ] + assert commandcode_profile.get_context_length("org/new-model") == 262_144 + + +class TestCommandCodeAnthropicProfile: + def test_profile_metadata(self, commandcode_anthropic_profile): + assert commandcode_anthropic_profile.name == "commandcode-anthropic" + assert commandcode_anthropic_profile.api_mode == "anthropic_messages" + assert commandcode_anthropic_profile.base_url == "https://api.commandcode.ai/provider" + assert commandcode_anthropic_profile.models_url == "https://api.commandcode.ai/provider/v1/models" + + def test_shim_adapts_messages_api_to_chat_completions(self): + anthropic_shim = importlib.import_module( + "plugins.model-providers.commandcode.anthropic_shim" + ) + CommandCodeAnthropicShim = anthropic_shim.CommandCodeAnthropicShim + + observed: dict[str, Any] = {} + + class _FakeCompletions: + def create(self, **kwargs): + observed.update(kwargs) + return SimpleNamespace( + id="chatcmpl_123", + model=kwargs["model"], + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace(content="pong", tool_calls=[]), + ) + ], + usage=SimpleNamespace(prompt_tokens=11, completion_tokens=7, total_tokens=18), + ) + + fake_client = SimpleNamespace( + chat=SimpleNamespace(completions=_FakeCompletions()) + ) + shim = CommandCodeAnthropicShim(fake_client, default_model="deepseek/deepseek-v4-pro") + + response = shim.messages.create( + system="Be terse.", + messages=[{"role": "user", "content": [{"type": "text", "text": "ping"}]}], + tools=[ + { + "name": "lookup", + "description": "Look something up", + "input_schema": {"type": "object", "properties": {}}, + } + ], + tool_choice="auto", + max_tokens=128, + ) + + assert observed["model"] == "deepseek/deepseek-v4-pro" + assert observed["messages"][0] == {"role": "system", "content": "Be terse."} + assert observed["messages"][1] == {"role": "user", "content": "ping"} + assert observed["tools"][0]["function"]["name"] == "lookup" + assert response.stop_reason == "end_turn" + assert response.content[0].type == "text" + assert response.content[0].text == "pong" diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index be5c56122eaf..6af385a45f1c 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -46,30 +46,18 @@ def test_bundled_plugins_discovered(): assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml" -def test_all_profiles_register(): - """After discovery, the registry must contain every bundled provider directory. - - This is an invariant — the number of profiles matches the number of plugin - directories, not a hardcoded count. Counts shift when providers are - added/removed; that's expected and shouldn't break CI. - """ +def test_all_34_profiles_register(): + """After discovery, the registry must contain exactly 34 distinct profiles.""" _clear_provider_caches() from providers import list_providers - plugins_dir = REPO_ROOT / "plugins" / "model-providers" - plugin_dir_count = sum(1 for c in plugins_dir.iterdir() if c.is_dir()) - profiles = list_providers() names = sorted(p.name for p in profiles) - # Some plugin __init__.py files register multiple profiles, so the registry - # count is >= the directory count (never less). - assert len(names) >= plugin_dir_count, ( - f"Expected at least {plugin_dir_count} profiles (one per plugin dir), got {len(names)}: {names}" - ) + assert len(names) == 34, f"Expected 34 profiles, got {len(names)}: {names}" # Spot-check representative providers from different categories for required in ( - "openrouter", "anthropic", "custom", "bedrock", "openai-codex", + "openrouter", "commandcode", "anthropic", "custom", "bedrock", "openai-codex", "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", ): assert required in names, f"Missing profile: {required}"