Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/model_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://<folder>/<model> 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

Expand Down
105 changes: 105 additions & 0 deletions plugins/model-providers/yandex/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions plugins/model-providers/yandex/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: yandex
kind: model-provider
version: 1.0.0
description: Yandex Cloud AI Studio (OpenAI-compatible Model Gallery)
author: pamnard
86 changes: 86 additions & 0 deletions tests/hermes_cli/test_yandex_provider.py
Original file line number Diff line number Diff line change
@@ -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"