fix
This commit is contained in:
@@ -94,6 +94,10 @@ YANDEX_STT_LANGUAGE=ru-RU
|
|||||||
VOICE_MAX_DURATION_SECONDS=120
|
VOICE_MAX_DURATION_SECONDS=120
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`YANDEX_CLOUD_FOLDER` — идентификатор каталога Yandex Cloud (например, значение
|
||||||
|
вида `b1g...`), а не имя модели. Имя и версия модели указываются отдельно в
|
||||||
|
`YANDEX_CLOUD_MODEL`, например `qwen3.6-35b-a3b/latest`.
|
||||||
|
|
||||||
Значения моделей можно посмотреть командой `/models`, а пользовательский выбор
|
Значения моделей можно посмотреть командой `/models`, а пользовательский выбор
|
||||||
сохранить командой `/model имя-модели`. Выбор хранится отдельно для локального и
|
сохранить командой `/model имя-модели`. Выбор хранится отдельно для локального и
|
||||||
облачного режимов. Короткое имя облачной модели автоматически преобразуется в URI
|
облачного режимов. Короткое имя облачной модели автоматически преобразуется в URI
|
||||||
@@ -130,6 +134,9 @@ docker run -d \
|
|||||||
kandrusyak-bot:local
|
kandrusyak-bot:local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ollama должна быть доступна контейнеру по адресу из `OLLAMA_BASE_URL`; адрес
|
Ollama должна быть доступна контейнеру по адресу из `OLLAMA_BASE_URL`; адрес
|
||||||
`localhost` внутри контейнера указывает на сам контейнер, а не на Linux-хост.
|
`localhost` внутри контейнера указывает на сам контейнер, а не на Linux-хост.
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def get_default_ollama_model() -> str:
|
|||||||
def get_yandex_cloud_folder() -> str:
|
def get_yandex_cloud_folder() -> str:
|
||||||
load_env_file()
|
load_env_file()
|
||||||
folder = os.getenv(YANDEX_CLOUD_FOLDER_ENV_NAME, "").strip()
|
folder = os.getenv(YANDEX_CLOUD_FOLDER_ENV_NAME, "").strip()
|
||||||
if not folder:
|
if not folder or folder == "":
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file."
|
f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ class YandexAIError(AIClientError):
|
|||||||
pass
|
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:
|
def create_async_sdk(folder_id: str) -> Any:
|
||||||
try:
|
try:
|
||||||
from yandex_ai_studio_sdk import AsyncAIStudio
|
from yandex_ai_studio_sdk import AsyncAIStudio
|
||||||
@@ -42,6 +51,14 @@ class YandexAIClient:
|
|||||||
def normalize_model(self, model: str) -> str:
|
def normalize_model(self, model: str) -> str:
|
||||||
normalized = model.strip()
|
normalized = model.strip()
|
||||||
if normalized.startswith("gpt://"):
|
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 normalized
|
||||||
return f"gpt://{self.folder_id}/{normalized.lstrip('/')}"
|
return f"gpt://{self.folder_id}/{normalized.lstrip('/')}"
|
||||||
|
|
||||||
@@ -49,7 +66,9 @@ class YandexAIClient:
|
|||||||
try:
|
try:
|
||||||
models = await self._sdk.chat.completions.list()
|
models = await self._sdk.chat.completions.list()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise YandexAIError(f"Не удалось получить список моделей: {exc}") from exc
|
raise YandexAIError(
|
||||||
|
f"Не удалось получить список моделей: {describe_yandex_error(exc)}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
model_names = {
|
model_names = {
|
||||||
str(uri)
|
str(uri)
|
||||||
@@ -67,19 +86,23 @@ class YandexAIClient:
|
|||||||
sdk_messages = [
|
sdk_messages = [
|
||||||
{
|
{
|
||||||
"role": message.get("role", "user"),
|
"role": message.get("role", "user"),
|
||||||
"text": message.get("content", ""),
|
"content": message.get("content", ""),
|
||||||
}
|
}
|
||||||
for message in messages
|
for message in messages
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
completion = self._sdk.models.completions(self.normalize_model(model))
|
completion = self._sdk.chat.completions(self.normalize_model(model))
|
||||||
if json_mode:
|
if json_mode:
|
||||||
completion = completion.configure(response_format="json")
|
completion = completion.configure(response_format="json")
|
||||||
result = await completion.run(sdk_messages, timeout=180)
|
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:
|
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():
|
if isinstance(content, str) and content.strip():
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
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:
|
class FakeCompletion:
|
||||||
@@ -22,21 +26,37 @@ class FakeCompletion:
|
|||||||
return [SimpleNamespace(text=" готово ")]
|
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):
|
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:
|
async def test_chat_uses_sdk_messages_and_json_mode(self) -> None:
|
||||||
completion = FakeCompletion()
|
completion = FakeCompletion()
|
||||||
requested_models = []
|
completions = FakeChatCompletions(completion, self._list_models)
|
||||||
|
|
||||||
def completions(model):
|
|
||||||
requested_models.append(model)
|
|
||||||
return completion
|
|
||||||
|
|
||||||
sdk = SimpleNamespace(
|
sdk = SimpleNamespace(
|
||||||
models=SimpleNamespace(completions=completions),
|
|
||||||
chat=SimpleNamespace(
|
chat=SimpleNamespace(
|
||||||
completions=SimpleNamespace(
|
completions=completions,
|
||||||
list=self._list_models,
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
client = YandexAIClient(folder_id="folder-id", sdk=sdk)
|
client = YandexAIClient(folder_id="folder-id", sdk=sdk)
|
||||||
@@ -51,17 +71,38 @@ class YandexAIClientTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(answer, "готово")
|
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.configuration, {"response_format": "json"})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
completion.messages,
|
completion.messages,
|
||||||
[
|
[
|
||||||
{"role": "system", "text": "Отвечай кратко"},
|
{"role": "system", "content": "Отвечай кратко"},
|
||||||
{"role": "user", "text": "Привет"},
|
{"role": "user", "content": "Привет"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(completion.timeout, 180)
|
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:
|
async def test_list_models_returns_sorted_uris(self) -> None:
|
||||||
sdk = SimpleNamespace(
|
sdk = SimpleNamespace(
|
||||||
chat=SimpleNamespace(
|
chat=SimpleNamespace(
|
||||||
|
|||||||
Reference in New Issue
Block a user