Add GigaChat model provider

This commit is contained in:
mirivlad 2026-04-08 12:28:57 +08:00
parent 7fac6fa41e
commit db89c14b37
8 changed files with 281 additions and 11 deletions

View File

@ -27,6 +27,7 @@ Qwen OAuth + OpenAI-compatible endpoint
- `model_router.py` - выбор провайдера и fallback policy - `model_router.py` - выбор провайдера и fallback policy
- `llm.py` - агентный цикл поверх абстракции провайдера - `llm.py` - агентный цикл поверх абстракции провайдера
- `oauth.py` - auth only для Qwen path - `oauth.py` - auth only для Qwen path
- `gigachat.py` - token management для GigaChat
## Что уже реализовано ## Что уже реализовано
@ -47,6 +48,7 @@ Qwen OAuth + OpenAI-compatible endpoint
- provider-based web search с приоритетом DashScope через Qwen OAuth - provider-based web search с приоритетом DashScope через Qwen OAuth
- model router с `qwen` как первым провайдером и fallback-ready архитектурой для `gigachat` и `yandexgpt` - model router с `qwen` как первым провайдером и fallback-ready архитектурой для `gigachat` и `yandexgpt`
- router умеет fallback не только по конфигу, но и при runtime-ошибке провайдера - router умеет fallback не только по конфигу, но и при runtime-ошибке провайдера
- реальный adapter для `gigachat` с token fetch и нормализацией function calling под внутренний agent loop
## Ограничения текущей реализации ## Ограничения текущей реализации
@ -77,7 +79,11 @@ cp serv/.env.example serv/.env
- `NEW_QWEN_STATE_DIR` - где хранить jobs и pending OAuth flows - `NEW_QWEN_STATE_DIR` - где хранить jobs и pending OAuth flows
- `NEW_QWEN_DEFAULT_PROVIDER` - основной model provider, сейчас по умолчанию `qwen` - `NEW_QWEN_DEFAULT_PROVIDER` - основной model provider, сейчас по умолчанию `qwen`
- `NEW_QWEN_FALLBACK_PROVIDERS` - fallback-цепочка провайдеров через запятую - `NEW_QWEN_FALLBACK_PROVIDERS` - fallback-цепочка провайдеров через запятую
- `NEW_QWEN_GIGACHAT_MODEL` - имя модели для будущего GigaChat adapter - `NEW_QWEN_GIGACHAT_MODEL` - имя модели GigaChat
- `NEW_QWEN_GIGACHAT_AUTH_KEY` - ключ авторизации GigaChat для `Authorization: Basic ...`
- `NEW_QWEN_GIGACHAT_SCOPE` - scope для получения access token, по умолчанию `GIGACHAT_API_PERS`
- `NEW_QWEN_GIGACHAT_API_BASE_URL` - базовый URL inference API GigaChat
- `NEW_QWEN_GIGACHAT_OAUTH_URL` - URL получения access token GigaChat
- `NEW_QWEN_YANDEXGPT_MODEL` - имя модели для будущего YandexGPT adapter - `NEW_QWEN_YANDEXGPT_MODEL` - имя модели для будущего YandexGPT adapter
- `NEW_QWEN_TOOL_POLICY` - режим инструментов: - `NEW_QWEN_TOOL_POLICY` - режим инструментов:
`full-access` - все инструменты `full-access` - все инструменты
@ -144,6 +150,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start
`GET /api/v1/auth/status` теперь также показывает: `GET /api/v1/auth/status` теперь также показывает:
- `ready`
- `available_providers`
- `default_provider` - `default_provider`
- `fallback_providers` - `fallback_providers`
- список `providers` с availability и capabilities - список `providers` с availability и capabilities

View File

@ -153,8 +153,18 @@ def start_auth_flow(
def ensure_auth(api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int) -> bool: def ensure_auth(api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int) -> bool:
status = get_json(f"{config.server_url}/api/v1/auth/status") status = get_json(f"{config.server_url}/api/v1/auth/status")
if status.get("authenticated"): if status.get("ready") or status.get("available_providers"):
return True return True
default_provider = status.get("default_provider")
fallback_providers = status.get("fallback_providers") or []
if default_provider != "qwen" and "qwen" not in fallback_providers:
api.send_message(
chat_id,
"На сервере нет доступных model provider-ов. "
f"Текущий default_provider: {default_provider}. "
"Для GigaChat/YandexGPT нужно настроить серверные credentials.",
)
return False
start_auth_flow(api, config, state, chat_id) start_auth_flow(api, config, state, chat_id)
return False return False
@ -561,6 +571,8 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
chat_id, chat_id,
"Сервер доступен.\n" "Сервер доступен.\n"
f"OAuth: {'configured' if status.get('authenticated') else 'not configured'}\n" f"OAuth: {'configured' if status.get('authenticated') else 'not configured'}\n"
f"ready: {status.get('ready')}\n"
f"available_providers: {', '.join(status.get('available_providers') or []) or '-'}\n"
f"default_provider: {status.get('default_provider')}\n" f"default_provider: {status.get('default_provider')}\n"
f"fallback_providers: {', '.join(status.get('fallback_providers') or []) or '-'}\n" f"fallback_providers: {', '.join(status.get('fallback_providers') or []) or '-'}\n"
f"resource_url: {status.get('resource_url')}\n" f"resource_url: {status.get('resource_url')}\n"

View File

@ -4,6 +4,10 @@ NEW_QWEN_MODEL=qwen3.6-plus
NEW_QWEN_DEFAULT_PROVIDER=qwen NEW_QWEN_DEFAULT_PROVIDER=qwen
NEW_QWEN_FALLBACK_PROVIDERS= NEW_QWEN_FALLBACK_PROVIDERS=
NEW_QWEN_GIGACHAT_MODEL=GigaChat NEW_QWEN_GIGACHAT_MODEL=GigaChat
NEW_QWEN_GIGACHAT_AUTH_KEY=
NEW_QWEN_GIGACHAT_SCOPE=GIGACHAT_API_PERS
NEW_QWEN_GIGACHAT_API_BASE_URL=https://gigachat.devices.sberbank.ru/api/v1
NEW_QWEN_GIGACHAT_OAUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth
NEW_QWEN_YANDEXGPT_MODEL=yandexgpt NEW_QWEN_YANDEXGPT_MODEL=yandexgpt
NEW_QWEN_WORKSPACE_ROOT=/home/mirivlad/git NEW_QWEN_WORKSPACE_ROOT=/home/mirivlad/git
NEW_QWEN_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions NEW_QWEN_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions

View File

@ -103,21 +103,31 @@ class AppState:
def auth_status(self) -> dict[str, Any]: def auth_status(self) -> dict[str, Any]:
creds = self.oauth.load_credentials() creds = self.oauth.load_credentials()
providers = self.providers.statuses()
available_providers = [
item["name"]
for item in providers
if item.get("available")
]
if not creds: if not creds:
return { return {
"authenticated": False, "authenticated": False,
"ready": bool(available_providers),
"available_providers": available_providers,
"default_provider": self.config.default_provider, "default_provider": self.config.default_provider,
"fallback_providers": self.config.fallback_providers, "fallback_providers": self.config.fallback_providers,
"providers": self.providers.statuses(), "providers": providers,
"tool_policy": self.config.tool_policy, "tool_policy": self.config.tool_policy,
"pending_flows": len(self.pending_device_flows), "pending_flows": len(self.pending_device_flows),
"pending_approvals": len(self.approvals.list_pending()), "pending_approvals": len(self.approvals.list_pending()),
} }
return { return {
"authenticated": True, "authenticated": True,
"ready": bool(available_providers),
"available_providers": available_providers,
"default_provider": self.config.default_provider, "default_provider": self.config.default_provider,
"fallback_providers": self.config.fallback_providers, "fallback_providers": self.config.fallback_providers,
"providers": self.providers.statuses(), "providers": providers,
"resource_url": creds.get("resource_url"), "resource_url": creds.get("resource_url"),
"expires_at": creds.get("expiry_date"), "expires_at": creds.get("expiry_date"),
"tool_policy": self.config.tool_policy, "tool_policy": self.config.tool_policy,

View File

@ -24,6 +24,10 @@ class ServerConfig:
default_provider: str default_provider: str
fallback_providers: list[str] fallback_providers: list[str]
gigachat_model: str gigachat_model: str
gigachat_auth_key: str
gigachat_scope: str
gigachat_api_base_url: str
gigachat_oauth_url: str
yandexgpt_model: str yandexgpt_model: str
workspace_root: Path workspace_root: Path
session_dir: Path session_dir: Path
@ -70,6 +74,16 @@ class ServerConfig:
if item.strip() if item.strip()
], ],
gigachat_model=os.environ.get("NEW_QWEN_GIGACHAT_MODEL", "GigaChat").strip(), gigachat_model=os.environ.get("NEW_QWEN_GIGACHAT_MODEL", "GigaChat").strip(),
gigachat_auth_key=os.environ.get("NEW_QWEN_GIGACHAT_AUTH_KEY", "").strip(),
gigachat_scope=os.environ.get("NEW_QWEN_GIGACHAT_SCOPE", "GIGACHAT_API_PERS").strip(),
gigachat_api_base_url=os.environ.get(
"NEW_QWEN_GIGACHAT_API_BASE_URL",
"https://gigachat.devices.sberbank.ru/api/v1",
).strip(),
gigachat_oauth_url=os.environ.get(
"NEW_QWEN_GIGACHAT_OAUTH_URL",
"https://ngw.devices.sberbank.ru:9443/api/v2/oauth",
).strip(),
yandexgpt_model=os.environ.get("NEW_QWEN_YANDEXGPT_MODEL", "yandexgpt").strip(), yandexgpt_model=os.environ.get("NEW_QWEN_YANDEXGPT_MODEL", "yandexgpt").strip(),
workspace_root=workspace_root.resolve(), workspace_root=workspace_root.resolve(),
session_dir=session_dir.resolve(), session_dir=session_dir.resolve(),

81
serv/gigachat.py Normal file
View File

@ -0,0 +1,81 @@
from __future__ import annotations
import json
import time
import uuid
from typing import Any
from urllib import error, parse, request
from config import ServerConfig
class GigaChatError(RuntimeError):
pass
class GigaChatAuthManager:
def __init__(self, config: ServerConfig) -> None:
self.config = config
self.token_path = config.state_dir / "gigachat_token.json"
self.token_path.parent.mkdir(parents=True, exist_ok=True)
def is_configured(self) -> bool:
return bool(self.config.gigachat_auth_key)
def _authorization_header(self) -> str:
raw = self.config.gigachat_auth_key.strip()
if not raw:
raise GigaChatError("GigaChat auth key is not configured")
if raw.lower().startswith("basic "):
return raw
return f"Basic {raw}"
def load_token(self) -> dict[str, Any] | None:
if not self.token_path.exists():
return None
try:
return json.loads(self.token_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def save_token(self, payload: dict[str, Any]) -> None:
self.token_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def fetch_token(self) -> dict[str, Any]:
data = parse.urlencode({"scope": self.config.gigachat_scope}).encode("utf-8")
req = request.Request(
self.config.gigachat_oauth_url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"RqUID": str(uuid.uuid4()),
"Authorization": self._authorization_header(),
},
method="POST",
)
try:
with request.urlopen(req, timeout=60) as response:
payload = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise GigaChatError(f"GigaChat token request failed with HTTP {exc.code}: {body}") from exc
token = {
"access_token": payload["access_token"],
"expires_at": int(payload["expires_at"]),
}
self.save_token(token)
return token
def get_valid_token(self) -> str:
if not self.is_configured():
raise GigaChatError("GigaChat auth key is not configured")
token = self.load_token()
now = int(time.time())
if token and int(token.get("expires_at", 0)) - now > 30:
return str(token["access_token"])
refreshed = self.fetch_token()
return str(refreshed["access_token"])

View File

@ -94,6 +94,7 @@ class QwenAgent:
"role": "assistant", "role": "assistant",
"content": content or "", "content": content or "",
"tool_calls": tool_calls, "tool_calls": tool_calls,
**({"functions_state_id": choice["functions_state_id"]} if choice.get("functions_state_id") else {}),
} }
) )
@ -129,6 +130,7 @@ class QwenAgent:
{ {
"role": "tool", "role": "tool",
"tool_call_id": call["id"], "tool_call_id": call["id"],
"name": tool_name,
"content": self.tools.encode_result(result), "content": self.tools.encode_result(result),
} }
) )
@ -148,6 +150,7 @@ class QwenAgent:
{ {
"role": "tool", "role": "tool",
"tool_call_id": call["id"], "tool_call_id": call["id"],
"name": tool_name,
"content": self.tools.encode_result(result), "content": self.tools.encode_result(result),
} }
) )

View File

@ -3,9 +3,11 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
import uuid
from urllib import error, request from urllib import error, request
from config import ServerConfig from config import ServerConfig
from gigachat import GigaChatAuthManager, GigaChatError
from oauth import OAuthError, QwenOAuthManager from oauth import OAuthError, QwenOAuthManager
@ -143,6 +145,148 @@ class QwenModelProvider(BaseModelProvider):
raise ModelProviderError(str(exc)) from exc raise ModelProviderError(str(exc)) from exc
class GigaChatModelProvider(BaseModelProvider):
name = "gigachat"
capabilities = ProviderCapabilities(
tool_calling=True,
web_search=False,
oauth_auth=False,
)
def __init__(self, config: ServerConfig, auth: GigaChatAuthManager) -> None:
super().__init__(config)
self.auth = auth
def is_available(self) -> bool:
return self.auth.is_configured()
def unavailable_reason(self) -> str | None:
if self.is_available():
return None
return "GigaChat auth key is not configured"
def model_name(self) -> str:
return self.config.gigachat_model
@staticmethod
def _convert_tool_schema(tool: dict[str, Any]) -> dict[str, Any]:
return dict(tool.get("function") or {})
def _convert_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
converted: list[dict[str, Any]] = []
for message in messages:
role = message.get("role")
if role in {"system", "user"}:
converted.append(
{
"role": role,
"content": message.get("content", ""),
}
)
continue
if role == "assistant":
payload: dict[str, Any] = {
"role": "assistant",
"content": message.get("content", ""),
}
tool_calls = message.get("tool_calls") or []
if tool_calls:
first_call = tool_calls[0]
raw_arguments = first_call.get("function", {}).get("arguments", "{}")
if isinstance(raw_arguments, str):
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
arguments = {"raw": raw_arguments}
else:
arguments = raw_arguments
payload["function_call"] = {
"name": first_call.get("function", {}).get("name"),
"arguments": arguments,
}
if message.get("functions_state_id"):
payload["functions_state_id"] = message["functions_state_id"]
converted.append(payload)
continue
if role == "tool":
converted.append(
{
"role": "function",
"name": message.get("name") or "tool_result",
"content": message.get("content", ""),
}
)
return converted
def _normalize_response(self, payload: dict[str, Any]) -> dict[str, Any]:
choices = payload.get("choices") or []
if not choices:
return payload
choice = choices[0]
message = dict(choice.get("message") or {})
normalized_message: dict[str, Any] = {
"role": "assistant",
"content": message.get("content", "") or "",
}
function_call = message.get("function_call")
if function_call:
arguments = function_call.get("arguments", {})
if not isinstance(arguments, str):
arguments = json.dumps(arguments, ensure_ascii=False)
normalized_message["tool_calls"] = [
{
"id": uuid.uuid4().hex,
"type": "function",
"function": {
"name": function_call.get("name"),
"arguments": arguments,
},
}
]
if message.get("functions_state_id"):
normalized_message["functions_state_id"] = message.get("functions_state_id")
choice["message"] = normalized_message
payload["choices"] = choices
return payload
def complete(self, completion_request: CompletionRequest) -> dict[str, Any]:
try:
access_token = self.auth.get_valid_token()
except GigaChatError as exc:
raise ModelProviderError(str(exc)) from exc
api_base = self.config.gigachat_api_base_url.rstrip("/")
payload: dict[str, Any] = {
"model": self.model_name(),
"messages": self._convert_messages(completion_request.messages),
}
if completion_request.tools:
payload["functions"] = [
self._convert_tool_schema(tool)
for tool in completion_request.tools
]
payload["function_call"] = "auto"
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request(
f"{api_base}/chat/completions",
data=data,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
},
method="POST",
)
try:
with request.urlopen(req, timeout=180) as response:
raw = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise ModelProviderError(
f"Provider {self.name} request failed with HTTP {exc.code}: {body}"
) from exc
return self._normalize_response(raw)
class ProviderRegistry: class ProviderRegistry:
def __init__(self, providers: list[BaseModelProvider]) -> None: def __init__(self, providers: list[BaseModelProvider]) -> None:
self._providers = {provider.name: provider for provider in providers} self._providers = {provider.name: provider for provider in providers}
@ -236,13 +380,7 @@ class ModelRouter:
def build_provider_registry(config: ServerConfig, oauth: QwenOAuthManager) -> ProviderRegistry: def build_provider_registry(config: ServerConfig, oauth: QwenOAuthManager) -> ProviderRegistry:
providers: list[BaseModelProvider] = [ providers: list[BaseModelProvider] = [
QwenModelProvider(config, oauth), QwenModelProvider(config, oauth),
UnavailableModelProvider( GigaChatModelProvider(config, GigaChatAuthManager(config)),
config,
name="gigachat",
model_name=config.gigachat_model,
reason="GigaChat provider is not implemented yet",
capabilities=ProviderCapabilities(),
),
UnavailableModelProvider( UnavailableModelProvider(
config, config,
name="yandexgpt", name="yandexgpt",