diff --git a/README.md b/README.md index 8c72c7c..52d8919 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ YANDEX_STT_LANGUAGE=ru-RU VOICE_MAX_DURATION_SECONDS=120 ``` +`YANDEX_CLOUD_FOLDER` — идентификатор каталога Yandex Cloud (например, значение +вида `b1g...`), а не имя модели. Имя и версия модели указываются отдельно в +`YANDEX_CLOUD_MODEL`, например `qwen3.6-35b-a3b/latest`. + Значения моделей можно посмотреть командой `/models`, а пользовательский выбор сохранить командой `/model имя-модели`. Выбор хранится отдельно для локального и облачного режимов. Короткое имя облачной модели автоматически преобразуется в URI @@ -130,6 +134,9 @@ docker run -d \ kandrusyak-bot:local ``` + + + Ollama должна быть доступна контейнеру по адресу из `OLLAMA_BASE_URL`; адрес `localhost` внутри контейнера указывает на сам контейнер, а не на Linux-хост. diff --git a/assistant_bot/config.py b/assistant_bot/config.py index 8c0b2f8..b383ce3 100644 --- a/assistant_bot/config.py +++ b/assistant_bot/config.py @@ -115,7 +115,7 @@ def get_default_ollama_model() -> str: def get_yandex_cloud_folder() -> str: load_env_file() folder = os.getenv(YANDEX_CLOUD_FOLDER_ENV_NAME, "").strip() - if not folder: + if not folder or folder == "": raise RuntimeError( f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file." ) diff --git a/assistant_bot/yandex_ai.py b/assistant_bot/yandex_ai.py index 6d96e1d..0c8db21 100644 --- a/assistant_bot/yandex_ai.py +++ b/assistant_bot/yandex_ai.py @@ -9,6 +9,15 @@ class YandexAIError(AIClientError): pass +def describe_yandex_error(exc: Exception) -> str: + response = getattr(exc, "response", None) + status_code = getattr(response, "status_code", None) + response_text = getattr(response, "text", None) + if status_code and isinstance(response_text, str) and response_text.strip(): + return f"HTTP {status_code}: {response_text.strip()[:800]}" + return str(exc) + + def create_async_sdk(folder_id: str) -> Any: try: from yandex_ai_studio_sdk import AsyncAIStudio @@ -42,6 +51,14 @@ class YandexAIClient: def normalize_model(self, model: str) -> str: normalized = model.strip() if normalized.startswith("gpt://"): + model_path = normalized.removeprefix("gpt://").strip("/") + parts = model_path.split("/") + if ( + not normalized.startswith(f"gpt://{self.folder_id}/") + and len(parts) == 2 + and parts[-1] in {"latest", "rc"} + ): + return f"gpt://{self.folder_id}/{model_path}" return normalized return f"gpt://{self.folder_id}/{normalized.lstrip('/')}" @@ -49,7 +66,9 @@ class YandexAIClient: try: models = await self._sdk.chat.completions.list() except Exception as exc: - raise YandexAIError(f"Не удалось получить список моделей: {exc}") from exc + raise YandexAIError( + f"Не удалось получить список моделей: {describe_yandex_error(exc)}" + ) from exc model_names = { str(uri) @@ -67,19 +86,23 @@ class YandexAIClient: sdk_messages = [ { "role": message.get("role", "user"), - "text": message.get("content", ""), + "content": message.get("content", ""), } for message in messages ] try: - completion = self._sdk.models.completions(self.normalize_model(model)) + completion = self._sdk.chat.completions(self.normalize_model(model)) if json_mode: completion = completion.configure(response_format="json") result = await completion.run(sdk_messages, timeout=180) - content = getattr(result[0], "text", None) + content = getattr(result, "text", None) + if content is None: + content = getattr(result[0], "text", None) except Exception as exc: - raise YandexAIError(f"Запрос к модели завершился ошибкой: {exc}") from exc + raise YandexAIError( + f"Запрос к модели завершился ошибкой: {describe_yandex_error(exc)}" + ) from exc if isinstance(content, str) and content.strip(): return content.strip() diff --git a/tests/test_yandex_ai.py b/tests/test_yandex_ai.py index 1779209..c827ed4 100644 --- a/tests/test_yandex_ai.py +++ b/tests/test_yandex_ai.py @@ -3,7 +3,11 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from assistant_bot.yandex_ai import YandexAIClient, YandexSpeechRecognizer +from assistant_bot.yandex_ai import ( + YandexAIClient, + YandexSpeechRecognizer, + describe_yandex_error, +) class FakeCompletion: @@ -22,21 +26,37 @@ class FakeCompletion: return [SimpleNamespace(text=" готово ")] +class FakeChatCompletions: + def __init__(self, completion, list_models) -> None: + self.completion = completion + self.list = list_models + self.requested_models = [] + + def __call__(self, model): + self.requested_models.append(model) + return self.completion + + class YandexAIClientTests(unittest.IsolatedAsyncioTestCase): + def test_http_error_includes_safe_response_body(self) -> None: + error = RuntimeError("forbidden") + error.response = SimpleNamespace( + status_code=403, + text='{"error":{"message":"folder mismatch"}}', + ) + + self.assertEqual( + describe_yandex_error(error), + 'HTTP 403: {"error":{"message":"folder mismatch"}}', + ) + async def test_chat_uses_sdk_messages_and_json_mode(self) -> None: completion = FakeCompletion() - requested_models = [] - - def completions(model): - requested_models.append(model) - return completion + completions = FakeChatCompletions(completion, self._list_models) sdk = SimpleNamespace( - models=SimpleNamespace(completions=completions), chat=SimpleNamespace( - completions=SimpleNamespace( - list=self._list_models, - ) + completions=completions, ), ) client = YandexAIClient(folder_id="folder-id", sdk=sdk) @@ -51,17 +71,38 @@ class YandexAIClientTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(answer, "готово") - self.assertEqual(requested_models, ["gpt://folder-id/yandexgpt"]) + self.assertEqual( + completions.requested_models, + ["gpt://folder-id/yandexgpt"], + ) self.assertEqual(completion.configuration, {"response_format": "json"}) self.assertEqual( completion.messages, [ - {"role": "system", "text": "Отвечай кратко"}, - {"role": "user", "text": "Привет"}, + {"role": "system", "content": "Отвечай кратко"}, + {"role": "user", "content": "Привет"}, ], ) self.assertEqual(completion.timeout, 180) + async def test_chat_repairs_legacy_uri_without_folder_id(self) -> None: + completion = FakeCompletion() + completions = FakeChatCompletions(completion, self._list_models) + sdk = SimpleNamespace( + chat=SimpleNamespace(completions=completions), + ) + client = YandexAIClient(folder_id="folder-id", sdk=sdk) + + await client.chat( + "gpt://qwen3.6-35b-a3b/latest", + [{"role": "user", "content": "Привет"}], + ) + + self.assertEqual( + completions.requested_models, + ["gpt://folder-id/qwen3.6-35b-a3b/latest"], + ) + async def test_list_models_returns_sorted_uris(self) -> None: sdk = SimpleNamespace( chat=SimpleNamespace(