diff --git a/.env.example b/.env.example index b7f3b008faf2..5d11cb666b03 100644 --- a/.env.example +++ b/.env.example @@ -124,6 +124,14 @@ # Optional base URL override: # XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1 +# ============================================================================= +# LLM PROVIDER (Yandex AI Studio) +# ============================================================================= +# Yandex Cloud Model Gallery — OpenAI-compatible LLM API. +# Get credentials at: https://aistudio.yandex.ru/ +# YANDEX_API_KEY=your_key_here +# YANDEX_FOLDER_ID=your_folder_id_here + # ============================================================================= # TOOL API KEYS # ============================================================================= diff --git a/cli-config.yaml.example b/cli-config.yaml.example index fb6912642ae9..891491abc1e8 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -26,6 +26,7 @@ model: # "huggingface" - Hugging Face Inference (requires: HF_TOKEN) # "nvidia" - NVIDIA NIM / build.nvidia.com (requires: NVIDIA_API_KEY) # "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY) + # "yandex" - Yandex AI Studio (requires: YANDEX_API_KEY, YANDEX_FOLDER_ID) # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index d7f8f3ea22ea..81193ec72ded 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -462,6 +462,12 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if provider in _AUTHORITATIVE_NATIVE_PROVIDERS: return name + # --- Yandex AI Studio: gpt:/// URIs --- + if provider == "yandex": + from plugins.model_providers.yandex import normalize_yandex_model + + return normalize_yandex_model(name) + # --- Custom & all others: pass through as-is --- return name diff --git a/plugins/model-providers/yandex/__init__.py b/plugins/model-providers/yandex/__init__.py new file mode 100644 index 000000000000..b35dec1e6d52 --- /dev/null +++ b/plugins/model-providers/yandex/__init__.py @@ -0,0 +1,105 @@ +"""Yandex Cloud AI Studio provider profile.""" + +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Any + +from providers import register_provider +from providers.base import ProviderProfile, _profile_user_agent + +_BASE_URL = "https://llm.api.cloud.yandex.net/v1" +_DEFAULT_MODEL = "deepseek-v4-flash/latest" + + +def _folder_id() -> str: + return os.environ.get("YANDEX_FOLDER_ID", "").strip().strip('"').strip("'") + + +def normalize_yandex_model(model_input: str) -> str: + """Resolve a config/user model id to a Yandex ``gpt://`` URI.""" + folder_id = _folder_id() + name = (model_input or "").strip() + if not name: + return name + if "${YANDEX_FOLDER_ID}" in name: + if not folder_id: + return name + name = name.replace("${YANDEX_FOLDER_ID}", folder_id) + if name.startswith("gpt://"): + return name + if not folder_id: + return name + return f"gpt://{folder_id}/{name.lstrip('/')}" + + +def _yandex_headers() -> dict[str, str]: + headers = {"x-data-logging-enabled": "false"} + folder_id = _folder_id() + if folder_id: + headers["x-folder-id"] = folder_id + return headers + + +class YandexProfile(ProviderProfile): + """Yandex AI Studio — folder headers read from env at access time.""" + + @property + def default_headers(self) -> dict[str, str]: + return _yandex_headers() + + @default_headers.setter + def default_headers(self, value: dict[str, str]) -> None: + # dataclass compatibility; ignore static assignment + return + + def fetch_models( + self, + *, + api_key: str | None = None, + timeout: float = 8.0, + ) -> list[str] | None: + url = (self.models_url or "").strip() or (self.base_url.rstrip("/") + "/models") + req = urllib.request.Request(url) + if api_key: + req.add_header("Authorization", f"Api-Key {api_key}") + req.add_header("Accept", "application/json") + req.add_header("User-Agent", _profile_user_agent()) + for key, value in _yandex_headers().items(): + req.add_header(key, value) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + items = data if isinstance(data, list) else data.get("data", []) + return [m["id"] for m in items if isinstance(m, dict) and "id" in m] + except Exception: + return None + + def build_api_kwargs_extras( + self, + *, + reasoning_config: dict | None = None, + model: str | None = None, + **context: Any, + ) -> tuple[dict[str, Any], dict[str, Any]]: + # Yandex OpenAI-compat rejects extra_body.thinking for DeepSeek V4 Flash. + return {}, {} + + +yandex = YandexProfile( + name="yandex", + aliases=("yandex-ai-studio", "yandex-aistudio"), + env_vars=("YANDEX_API_KEY", "YANDEX_FOLDER_ID"), + display_name="Yandex AI Studio", + description="Yandex Cloud AI Studio — Model Gallery", + signup_url="https://aistudio.yandex.ru/", + base_url=_BASE_URL, + hostname="llm.api.cloud.yandex.net", + auth_type="api_key", + fallback_models=(_DEFAULT_MODEL,), + default_aux_model=_DEFAULT_MODEL, +) + +register_provider(yandex) diff --git a/plugins/model-providers/yandex/plugin.yaml b/plugins/model-providers/yandex/plugin.yaml new file mode 100644 index 000000000000..18e4d7528c00 --- /dev/null +++ b/plugins/model-providers/yandex/plugin.yaml @@ -0,0 +1,5 @@ +name: yandex +kind: model-provider +version: 1.0.0 +description: Yandex Cloud AI Studio (OpenAI-compatible Model Gallery) +author: pamnard diff --git a/tests/hermes_cli/test_yandex_provider.py b/tests/hermes_cli/test_yandex_provider.py new file mode 100644 index 000000000000..bffb492ccde8 --- /dev/null +++ b/tests/hermes_cli/test_yandex_provider.py @@ -0,0 +1,86 @@ +"""Tests for Yandex AI Studio provider support.""" + +from __future__ import annotations + +import pytest + +from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider +from plugins.model_providers.yandex import normalize_yandex_model + + +class TestYandexProviderRegistry: + def test_registered(self): + assert "yandex" in PROVIDER_REGISTRY + + def test_inference_base_url(self): + assert ( + PROVIDER_REGISTRY["yandex"].inference_base_url + == "https://llm.api.cloud.yandex.net/v1" + ) + + def test_api_key_env_vars(self): + assert "YANDEX_API_KEY" in PROVIDER_REGISTRY["yandex"].api_key_env_vars + + +class TestYandexAliases: + @pytest.mark.parametrize("alias", ["yandex", "yandex-ai-studio", "yandex-aistudio"]) + def test_alias_resolves(self, alias, monkeypatch): + monkeypatch.setenv("YANDEX_API_KEY", "test-key-1234567890123456") + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + assert resolve_provider(alias) == "yandex" + + +class TestNormalizeYandexModel: + def test_bare_model_becomes_gpt_uri(self, monkeypatch): + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + assert ( + normalize_yandex_model("deepseek-v4-flash/latest") + == "gpt://b1folder/deepseek-v4-flash/latest" + ) + + def test_expands_folder_env_placeholder(self, monkeypatch): + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + assert ( + normalize_yandex_model("gpt://${YANDEX_FOLDER_ID}/deepseek-v4-flash/latest") + == "gpt://b1folder/deepseek-v4-flash/latest" + ) + + def test_passthrough_existing_gpt_uri(self, monkeypatch): + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + uri = "gpt://b1folder/deepseek-v4-flash/latest" + assert normalize_yandex_model(uri) == uri + + def test_missing_folder_leaves_placeholder(self, monkeypatch): + monkeypatch.delenv("YANDEX_FOLDER_ID", raising=False) + raw = "gpt://${YANDEX_FOLDER_ID}/deepseek-v4-flash/latest" + assert normalize_yandex_model(raw) == raw + + def test_model_normalize_integration(self, monkeypatch): + from hermes_cli.model_normalize import normalize_model_for_provider + + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + assert ( + normalize_model_for_provider("deepseek-v4-flash/latest", "yandex") + == "gpt://b1folder/deepseek-v4-flash/latest" + ) + + +class TestYandexProfileHooks: + def test_no_thinking_extra_body(self): + from providers import get_provider_profile + + profile = get_provider_profile("yandex") + extra, top = profile.build_api_kwargs_extras( + model="gpt://b1folder/deepseek-v4-flash/latest", + reasoning_config={"enabled": True, "effort": "medium"}, + ) + assert extra == {} + assert top == {} + + def test_dynamic_folder_header(self, monkeypatch): + from providers import get_provider_profile + + monkeypatch.setenv("YANDEX_FOLDER_ID", "b1folder") + profile = get_provider_profile("yandex") + assert profile.default_headers["x-folder-id"] == "b1folder" + assert profile.default_headers["x-data-logging-enabled"] == "false"