vertex, bedrock and copilot-acp each overrode ProviderProfile.fetch_models with an identical `return None`, and the overrides could not simply be deleted because the base implementation derives a URL from base_url and would GET e.g. bedrock-runtime.../models or acp://copilot/models. ProviderProfile now carries `supports_model_listing` (default True); the base fetch_models returns None before touching the network when it is False, and the three SDK/subprocess-backed profiles set it in their constructors instead of overriding. Separately, two catalog GETs still went through bare urllib.request.urlopen: commandcode's fetch_models and hermes_cli.models._fetch_ai_gateway_models (which sends the AI Gateway bearer). Both now use the redirect-safe open_credentialed_url path (_urlopen_model_catalog_request in models.py, also applied to the unauthenticated fetch_ai_gateway_models for consistency). This is a security behavior change: a cross-origin redirect from either endpoint no longer forwards the Authorization/attribution headers to the redirect target. The AI Gateway tests that patched the global urlopen are repointed at hermes_cli.models._urlopen_model_catalog_request (the seam tests/hermes_cli/test_models.py already uses), the commandcode test patches open_credentialed_url on the plugin module, and two invariant tests pin the no-network guard and the bearer routing.
97 lines
4.6 KiB
Python
97 lines
4.6 KiB
Python
"""CommandCode provider profiles: ``commandcode`` (chat_completions) and
|
|
``commandcode-anthropic`` (anthropic_messages, Bearer auth — see
|
|
``agent/anthropic_adapter.py``). Same key and base URL for both."""
|
|
|
|
import json
|
|
import logging
|
|
import urllib.request
|
|
|
|
from hermes_cli.urllib_security import open_credentialed_url
|
|
from providers import register_provider
|
|
from providers.base import ProviderProfile, _profile_user_agent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_COMMANDCODE_BASE = "https://api.commandcode.ai/provider/v1"
|
|
_COMMANDCODE_MODELS_URL = f"{_COMMANDCODE_BASE}/models"
|
|
|
|
|
|
class CommandCodeProfile(ProviderProfile):
|
|
"""CommandCode — OpenAI-compatible chat completions endpoint."""
|
|
|
|
def fetch_models(
|
|
self, *, api_key: str | None = None, base_url: str | None = None, timeout: float = 8.0
|
|
) -> list[str] | None:
|
|
"""Public (unauthenticated) /models endpoint. The picker passes base_url
|
|
unconditionally, so only a value differing from the default is a custom endpoint."""
|
|
caller_base = (base_url or "").strip().rstrip("/")
|
|
custom = caller_base and caller_base != _COMMANDCODE_BASE
|
|
models_url = caller_base + "/models" if custom else _COMMANDCODE_MODELS_URL
|
|
try:
|
|
req = urllib.request.Request(models_url)
|
|
req.add_header("Accept", "application/json")
|
|
req.add_header("User-Agent", _profile_user_agent())
|
|
with open_credentialed_url(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
return [m["id"] for m in data.get("data", []) if isinstance(m, dict) and "id" in m]
|
|
except Exception as exc:
|
|
logger.debug("fetch_models(commandcode): %s", exc)
|
|
return None
|
|
|
|
|
|
def build_api_kwargs_extras(
|
|
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
|
|
) -> tuple[dict, dict]:
|
|
"""DeepSeek ids (``deepseek/deepseek-v4-flash``) get the native DeepSeek wire
|
|
controls: DeepSeek V4+ defaults to thinking when ``thinking`` is omitted, so
|
|
without them ``/reasoning`` never reaches the request (#95232). Other model
|
|
families stay a no-op — CommandCode declares no reasoning vocabulary for them."""
|
|
m = (model or "").strip()
|
|
if not m.lower().startswith("deepseek/") or len(m) <= len("deepseek/"):
|
|
return {}, {}
|
|
from plugins.model_providers.deepseek import deepseek as _deepseek_profile
|
|
|
|
return _deepseek_profile.build_api_kwargs_extras(
|
|
reasoning_config=reasoning_config, model=m.split("/", 1)[1], **context,
|
|
)
|
|
|
|
|
|
class CommandCodeAnthropicProfile(CommandCodeProfile):
|
|
"""CommandCode — Anthropic Messages API-compatible endpoint."""
|
|
|
|
def fetch_models(
|
|
self, *, api_key: str | None = None, base_url: str | None = None, timeout: float = 8.0
|
|
) -> list[str] | None:
|
|
"""Public /models endpoint, filtered to Anthropic-family models."""
|
|
all_models = super().fetch_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
return None if all_models is None else [m for m in all_models if m.startswith("claude-")]
|
|
|
|
|
|
commandcode = CommandCodeProfile(
|
|
name="commandcode", aliases=("commandcode-chat",), api_mode="chat_completions",
|
|
# Same key as the anthropic profile; distinct base-URL override vars so each
|
|
# profile renders its own card on the desktop Keys tab (rows keyed by env var).
|
|
env_vars=("COMMANDCODE_API_KEY", "COMMANDCODE_BASE_URL"),
|
|
display_name="CommandCode", description="CommandCode — 20+ models via OpenAI-compatible API",
|
|
signup_url="https://commandcode.ai/", base_url=_COMMANDCODE_BASE, models_url=_COMMANDCODE_MODELS_URL,
|
|
fallback_models=(
|
|
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "Qwen/Qwen3.7-Max", "Qwen/Qwen3.6-Plus",
|
|
"moonshotai/Kimi-K2.6", "zai-org/GLM-5.1", "MiniMaxAI/MiniMax-M2.7", "stepfun/Step-3.5-Flash",
|
|
"xiaomi/mimo-v2.5-pro", "google/gemini-3.5-flash", "gpt-5.5",
|
|
),
|
|
default_aux_model="deepseek/deepseek-v4-flash",
|
|
)
|
|
|
|
commandcode_anthropic = CommandCodeAnthropicProfile(
|
|
name="commandcode-anthropic", aliases=("commandcode-claude",), api_mode="anthropic_messages",
|
|
env_vars=("COMMANDCODE_API_KEY", "COMMANDCODE_ANTHROPIC_BASE_URL"),
|
|
display_name="CommandCode (Anthropic)",
|
|
description="CommandCode — Claude models via Anthropic Messages API",
|
|
signup_url="https://commandcode.ai/", base_url=_COMMANDCODE_BASE, models_url=_COMMANDCODE_MODELS_URL,
|
|
fallback_models=("claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5-20251001"),
|
|
default_aux_model="claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
register_provider(commandcode)
|
|
register_provider(commandcode_anthropic)
|