commit 428cff8c54bc4a291338521cf262c2f005885852 Author: Виктор Date: Sun Aug 23 16:58:07 2026 +0900 Клиент к ИИ-платформе REG.RU Обёртка над ai.reg.cloud. Протокол совместим с OpenAI, но у платформы свои особенности: ключ бывает открыт не ко всем моделям, отказ приходит отдельным кодом, картинки и речь доступны не всем. Что внутри: - синхронный и асинхронный клиенты - каскад моделей на случай, если часть закрыта или занята - ремонт оборванного json из ответов - чистка типографики и отлов иероглифов в русском тексте Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8c9556 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +dist/ +build/ +*.egg-info/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c417a6 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# regcloud-ai + +Клиент к ИИ-платформе REG.RU, домен `ai.reg.cloud`. + +Протокол там совместим с OpenAI, но есть особенности, ради которых и написана обёртка. +Ключ бывает открыт не ко всем моделям, отказ приходит отдельным кодом, а список +доступного удобно спрашивать заранее. Плюс тут же лежат мелочи, которые всё равно +приходится писать в каждом проекте: ремонт оборванного json, чистка типографики +и отлов иероглифов, которые модели иногда роняют в русский текст. + +## Установка + +```bash +pip install httpx +# дальше просто положить пакет рядом или поставить из этого репозитория +pip install git+https://git.stackworks.ru/viktor/regcloud-ai.git +``` + +## Как пользоваться + +```python +from regcloud_ai import RegCloudAI + +ai = RegCloudAI() # ключ берётся из REGCLOUD_AI_KEY +print(ai.ask("Объясни в двух словах, что такое трансформер")) +``` + +Ответ приходит объектом, где кроме текста лежит расход токенов и время. + +```python +answer = ai.chat("Напиши заголовок ролика про нейросети", model="qwen3.7-max") +print(answer.text, answer.usage.total_tokens, answer.seconds) +``` + +Каскад на случай, если модель закрыта для ключа или занята. + +```python +answer = ai.cascade("Твой запрос", models=["claude-5-sonnet", "gpt-5.5", "qwen3.7-max"]) +``` + +Асинхронно всё то же самое. + +```python +from regcloud_ai import AsyncRegCloudAI + +async with AsyncRegCloudAI() as ai: + print(await ai.ask("Привет")) +``` + +## Что доступно на платформе + +На август 2026 платформа отдаёт двадцать девять моделей, среди них claude-5-sonnet, +gpt-5.5, gemini-3.1-pro, grok-4.3, семейства qwen, glm, deepseek, kimi и minimax. +Точный список всегда спрашивайте у самой платформы. + +```python +print(RegCloudAI().models()) +``` + +Замечание про доступы. Ключ может быть выдан только на часть моделей. Тогда обращение +к остальным вернёт `ModelAccessError`. Картинки, синтез и распознавание речи на платформе +есть, но открываются отдельно, обычному текстовому ключу они недоступны. + +## Мелочи, которые обычно нужны + +```python +from regcloud_ai.client import extract_json, normalize, foreign_chars + +data = extract_json(answer.text) # вытащит json даже из оборванного ответа +text = normalize(answer.text) # длинные тире и ёлочки станут обычными +bad = foreign_chars(text) # иероглифы, если модель их насыпала +``` + +## Замеры + +Проверено на живых запросах 23.08.2026, время до полного ответа на коротком запросе. + +| Модель | Время | +|---|---| +| qwen3.7-max | 2.9 с | +| deepseek-v4-flash | 4.5 с | +| gpt-5.5 | 5.3 с | +| claude-5-sonnet | 5.5 с | +| gemini-3.5-flash | 5.6 с | +| glm-5.2 | 7.1 с | diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..3ce362d --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,13 @@ +"""Простейший пример. Перед запуском положите ключ в REGCLOUD_AI_KEY.""" +from regcloud_ai import RegCloudAI + +ai = RegCloudAI() + +print("доступные модели:") +for m in ai.models(): + print(" ", m) + +answer = ai.chat("Придумай короткий заголовок для ролика про локальные нейросети", + model="qwen3.7-max", max_tokens=200) +print(f"\n{answer.text}") +print(f"модель {answer.model}, токенов {answer.usage.total_tokens}, {answer.seconds}с") diff --git a/examples/cascade_async.py b/examples/cascade_async.py new file mode 100644 index 0000000..c4c528f --- /dev/null +++ b/examples/cascade_async.py @@ -0,0 +1,19 @@ +"""Каскад моделей асинхронно. Идём сверху вниз, пока кто-нибудь не ответит.""" +import asyncio +from regcloud_ai import AsyncRegCloudAI +from regcloud_ai.client import normalize, foreign_chars + +CASCADE = ["claude-5-sonnet", "gpt-5.5", "qwen3.7-max", "glm-5.2"] + +async def main(): + async with AsyncRegCloudAI() as ai: + answer = await ai.cascade( + "Напиши два живых предложения про нейросети. Без канцелярита и без двоеточий.", + models=CASCADE, max_tokens=300) + text = normalize(answer.text) + print(f"[{answer.model}, {answer.seconds}с] {text}") + bad = foreign_chars(text) + if bad: + print("внимание, чужие символы в тексте:", " ".join(bad)) + +asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56de1f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "regcloud-ai" +version = "0.1.0" +description = "Клиент к ИИ-платформе REG.RU (ai.reg.cloud)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["httpx>=0.27"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["regcloud_ai"] diff --git a/regcloud_ai/__init__.py b/regcloud_ai/__init__.py new file mode 100644 index 0000000..172643e --- /dev/null +++ b/regcloud_ai/__init__.py @@ -0,0 +1,16 @@ +"""Клиент к ИИ-платформе REG.RU (ai.reg.cloud). + +Протокол совместим с OpenAI, но у платформы свои особенности, ради которых +и написана эта обёртка. Ключ может быть открыт не ко всем моделям, ответ об +отказе приходит с понятным кодом, а список доступного удобно спрашивать заранее. + + from regcloud_ai import RegCloudAI + + ai = RegCloudAI() # ключ возьмётся из REGCLOUD_AI_KEY + print(ai.ask("Привет, как дела?")) +""" + +from .client import RegCloudAI, AsyncRegCloudAI, RegCloudError, ModelAccessError + +__all__ = ["RegCloudAI", "AsyncRegCloudAI", "RegCloudError", "ModelAccessError"] +__version__ = "0.1.0" diff --git a/regcloud_ai/client.py b/regcloud_ai/client.py new file mode 100644 index 0000000..03844fa --- /dev/null +++ b/regcloud_ai/client.py @@ -0,0 +1,296 @@ +"""Клиент к ai.reg.cloud. Синхронный и асинхронный.""" +from __future__ import annotations + +import asyncio +import json +import os +import re +import time +import unicodedata +from dataclasses import dataclass +from typing import Any, Iterable + +import httpx + +BASE_URL = "https://ai.reg.cloud/v1" +KEY_ENV = "REGCLOUD_AI_KEY" + +# Проверено 23.08.2026. Ключи бывают открыты только к части моделей, +# полный список всегда спрашивайте у самой платформы через models(). +KNOWN_MODELS = ( + "claude-5-sonnet", "claude-4.5-haiku", + "gpt-5.5", "gpt-5.1-instant", "gpt-5.1-codex-mini", "gpt-5.3-codex", "gpt-oss-120b", + "gemini-3.1-pro", "gemini-3.5-flash", + "grok-4.3", + "qwen3.7-max", "qwen3.6-plus", "qwen3.6-27b", "qwen3.5-397b-a17b", "qwen3-coder", "qwen3-235b-a22b", + "glm-5.2", "glm-5-turbo", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-r1", + "minimax-m3", "minimax-m2.7", "kimi-k2.6", "kimi-k2.7-code", + "mistral-large", "mistral-small", "command-r-plus", "gemma-4-26b-a4b-it", +) + + +class RegCloudError(RuntimeError): + """Платформа ответила ошибкой.""" + + +class ModelAccessError(RegCloudError): + """Ключ не открыт к этой модели. Спросите список через models().""" + + +@dataclass +class Usage: + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + @classmethod + def parse(cls, raw: dict | None) -> "Usage": + raw = raw or {} + return cls(int(raw.get("prompt_tokens") or 0), + int(raw.get("completion_tokens") or 0), + int(raw.get("total_tokens") or 0)) + + +@dataclass +class Answer: + text: str + model: str + usage: Usage + seconds: float + + def __str__(self) -> str: + return self.text + + +class _Common: + """Общая часть синхронного и асинхронного клиентов.""" + + def __init__(self, key: str | None = None, base_url: str = BASE_URL, + timeout: float = 300.0, retries: int = 3): + self.key = key or os.environ.get(KEY_ENV, "") + if not self.key: + raise RegCloudError(f"нет ключа, передайте его явно или положите в {KEY_ENV}") + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.retries = retries + + @property + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"} + + def _payload(self, prompt: str, system: str, model: str, temperature: float, + max_tokens: int, json_mode: bool, messages: list | None, extra: dict) -> dict: + if messages is None: + messages = ([{"role": "system", "content": system}] if system else []) \ + + [{"role": "user", "content": prompt}] + body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} + # у части моделей температура фиксирована, тогда её лучше не слать вовсе + if temperature is not None: + body["temperature"] = temperature + if json_mode: + body["response_format"] = {"type": "json_object"} + body.update(extra) + return body + + @staticmethod + def _raise_for(status: int, text: str, model: str) -> None: + if status == 403 and "model_access" in text: + raise ModelAccessError(f"ключ не открыт к модели {model}. {text[:200]}") + if status == 429: + raise RegCloudError(f"лимит запросов, {text[:200]}") + raise RegCloudError(f"платформа ответила {status}: {text[:300]}") + + @staticmethod + def _read(data: dict, model: str, started: float) -> Answer: + choices = data.get("choices") or [] + if not choices: + raise RegCloudError(f"пустой ответ: {json.dumps(data, ensure_ascii=False)[:200]}") + text = (choices[0].get("message") or {}).get("content") or "" + return Answer(text.strip(), data.get("model", model), + Usage.parse(data.get("usage")), round(time.time() - started, 2)) + + +class RegCloudAI(_Common): + """Обычный синхронный клиент.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._http = httpx.Client(timeout=self.timeout) + + def models(self) -> list[str]: + r = self._http.get(f"{self.base_url}/models", headers=self._headers) + if r.status_code != 200: + self._raise_for(r.status_code, r.text, "-") + return [m["id"] for m in r.json().get("data", [])] + + def chat(self, prompt: str = "", *, system: str = "", model: str = "claude-5-sonnet", + temperature: float | None = 0.7, max_tokens: int = 4000, + json_mode: bool = False, messages: list | None = None, **extra) -> Answer: + body = self._payload(prompt, system, model, temperature, max_tokens, json_mode, messages, extra) + started = time.time() + last = "не пробовали" + + for attempt in range(self.retries): + r = self._http.post(f"{self.base_url}/chat/completions", headers=self._headers, json=body) + if r.status_code == 200: + return self._read(r.json(), model, started) + if r.status_code in (401, 403, 404): + self._raise_for(r.status_code, r.text, model) + last = f"{r.status_code} {r.text[:200]}" + time.sleep(3 * (attempt + 1)) + raise RegCloudError(f"{model}: {last}") + + def ask(self, prompt: str, **kw) -> str: + """Короткий путь, когда нужен только текст ответа.""" + return self.chat(prompt, **kw).text + + def cascade(self, prompt: str, models: Iterable[str], **kw) -> Answer: + """Идём по списку моделей, пока какая-нибудь не ответит. + Удобно, когда часть моделей закрыта для ключа или временно занята.""" + errors = [] + for model in models: + try: + return self.chat(prompt, model=model, **kw) + except RegCloudError as exc: + errors.append(f"{model}: {str(exc)[:90]}") + raise RegCloudError("ни одна модель не ответила. " + " | ".join(errors)) + + def close(self) -> None: + self._http.close() + + def __enter__(self): return self + def __exit__(self, *a): self.close() + + +class AsyncRegCloudAI(_Common): + """То же самое, но асинхронно.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._http = httpx.AsyncClient(timeout=self.timeout) + + async def models(self) -> list[str]: + r = await self._http.get(f"{self.base_url}/models", headers=self._headers) + if r.status_code != 200: + self._raise_for(r.status_code, r.text, "-") + return [m["id"] for m in r.json().get("data", [])] + + async def chat(self, prompt: str = "", *, system: str = "", model: str = "claude-5-sonnet", + temperature: float | None = 0.7, max_tokens: int = 4000, + json_mode: bool = False, messages: list | None = None, **extra) -> Answer: + body = self._payload(prompt, system, model, temperature, max_tokens, json_mode, messages, extra) + started = time.time() + last = "не пробовали" + + for attempt in range(self.retries): + r = await self._http.post(f"{self.base_url}/chat/completions", headers=self._headers, json=body) + if r.status_code == 200: + return self._read(r.json(), model, started) + if r.status_code in (401, 403, 404): + self._raise_for(r.status_code, r.text, model) + last = f"{r.status_code} {r.text[:200]}" + await asyncio.sleep(3 * (attempt + 1)) + raise RegCloudError(f"{model}: {last}") + + async def ask(self, prompt: str, **kw) -> str: + return (await self.chat(prompt, **kw)).text + + async def cascade(self, prompt: str, models: Iterable[str], **kw) -> Answer: + errors = [] + for model in models: + try: + return await self.chat(prompt, model=model, **kw) + except RegCloudError as exc: + errors.append(f"{model}: {str(exc)[:90]}") + raise RegCloudError("ни одна модель не ответила. " + " | ".join(errors)) + + async def close(self) -> None: + await self._http.aclose() + + async def __aenter__(self): return self + async def __aexit__(self, *a): await self.close() + + +# ---------- разбор ответов ---------- + +def extract_json(text: str) -> Any: + """Модели заворачивают json в тройные кавычки и обрывают его на середине. + Достаём и по возможности чиним.""" + text = text.strip() + if "```" in text: + for part in text.split("```")[1:]: + part = part.strip() + if part.startswith("json"): + part = part[4:] + if part.strip().startswith(("{", "[")): + text = part.strip() + break + start = min((i for i in (text.find("{"), text.find("[")) if i >= 0), default=-1) + if start > 0: + text = text[start:] + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + stack, in_str, escaped = [], False, False + cut, cut_stack = None, [] + for i, ch in enumerate(text): + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + cut, cut_stack = i + 1, list(stack) + continue + if ch == '"': + in_str = True + elif ch in "{[": + stack.append("}" if ch == "{" else "]") + elif ch in "}]": + if stack and stack[-1] == ch: + stack.pop() + cut, cut_stack = i + 1, list(stack) + + for end, st in ((len(text), stack), (cut, cut_stack)): + if not end: + continue + head = text[:end].rstrip().rstrip(",") + for _ in range(3): + try: + return json.loads(head + "".join(reversed(st))) + except json.JSONDecodeError: + comma = max(head.rfind(","), head.rfind("["), head.rfind("{")) + if comma <= 0: + break + head = head[:comma].rstrip().rstrip(",") + raise RegCloudError(f"не разобрал json: {text[:200]}") + + +TYPO = {"—": "-", "–": "-", "‑": "-", "−": "-", " ": " ", "​": "", + "“": '"', "”": '"', "«": '"', "»": '"', "‘": "'", "’": "'", "…": "..."} + + +def normalize(text: str) -> str: + """Приводит типографику к простому виду. Длинные тире и ёлочки ломают + субтитры и синтез речи, поэтому в текстах для озвучки их лучше не держать.""" + for a, b in TYPO.items(): + text = text.replace(a, b) + return re.sub(r"[ \t]+", " ", text).strip() + + +def foreign_chars(text: str) -> list[str]: + """Дешёвые модели иногда вставляют иероглифы прямо в русский текст. + Ловим их до того, как текст уйдёт в дело.""" + bad = [] + for ch in text: + code = ord(ch) + if code < 128 or ch.isspace() or 0x400 <= code <= 0x4FF: + continue + if unicodedata.category(ch) in ("Pd", "Pi", "Pf", "Po", "Sm", "Sc", "So", "Zs", "Mn"): + continue + bad.append(ch) + return sorted(set(bad))