From 316eefe58a374b84874311253d03ec11d19a3e3f Mon Sep 17 00:00:00 2001 From: Azimkin <112006710+Azimkin@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:35:08 +0200 Subject: [PATCH] Switch LLM provider to z.ai GLM --- README.md | 6 +- config.example.toml | 6 +- pyproject.toml | 2 +- src/llm_trade_bot/agent.py | 14 ++-- src/llm_trade_bot/cli.py | 2 +- src/llm_trade_bot/config.py | 28 +++---- src/llm_trade_bot/zai_llm.py | 148 +++++++++++++++++++++++++++++++++++ tests/test_agent_tools.py | 4 +- tests/test_config.py | 10 +-- tests/test_zai_llm.py | 34 ++++++++ 10 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 src/llm_trade_bot/zai_llm.py create mode 100644 tests/test_zai_llm.py diff --git a/README.md b/README.md index 931d228..55bc226 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LLM Trade Bot -Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram. +Автономный GLM-агент через **z.ai** для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram. `linear` — публичное имя USDⓈ-M Futures: оно намеренно сохранено в инструментах агента и промптах. Production endpoints не поддерживаются. Для Binance адреса Spot Testnet и USDⓈ-M Futures Demo Testnet зафиксированы в коде и не настраиваются через TOML. @@ -24,10 +24,12 @@ 2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. 3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом. 4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram. -5. Получите DeepSeek API key и Brave Search API key. +5. Получите z.ai API key и Brave Search API key. 6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`. 7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git. +Для GLM используйте раздел `[zai]`: базовый URL `https://api.z.ai/api/paas/v4` и модель `glm-4.6` уже заданы в примере. Ключ создаётся в z.ai; раздел `[deepseek]` больше не поддерживается. + ```powershell Copy-Item config.example.toml config.toml Copy-Item prompts.example.md prompts.md diff --git a/config.example.toml b/config.example.toml index c6c7718..40b3a9c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,7 +1,7 @@ -[deepseek] +[zai] api_key = "replace-me" -base_url = "https://api.deepseek.com" -model = "deepseek-flash" +base_url = "https://api.z.ai/api/paas/v4" +model = "glm-4.6" thinking = true max_tool_rounds = 256 diff --git a/pyproject.toml b/pyproject.toml index c76ab27..b085537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "llm-trade-bot" version = "0.1.0" -description = "Autonomous DeepSeek trading agent for Bybit Demo" +description = "Autonomous GLM trading agent for Bybit Demo" readme = "README.md" authors = [ { name = "Azimkin", email = "112006710+Azimkin@users.noreply.github.com" } diff --git a/src/llm_trade_bot/agent.py b/src/llm_trade_bot/agent.py index fae93bb..34c92d5 100644 --- a/src/llm_trade_bot/agent.py +++ b/src/llm_trade_bot/agent.py @@ -15,7 +15,6 @@ from micro_agents import ( Injected, LlmMessage, MessageAdded, - OpenAIBasedLLM, SessionContext, Tool, ToolCallFailed, @@ -31,6 +30,7 @@ from llm_trade_bot.models import Category, OpenTradeRequest, OrderType, Side from llm_trade_bot.prompts import PromptStore from llm_trade_bot.risk import RiskEngine from llm_trade_bot.web_tools import SafeWebTools +from llm_trade_bot.zai_llm import ZaiLLM class AgentTools: @@ -353,18 +353,18 @@ class AgentRunner: self.exchange = exchange self.bybit = exchange self.prompts = PromptStore(config.prompts.file_path) - llm = OpenAIBasedLLM( - api_url=config.deepseek.base_url, - api_key=config.deepseek.api_key, - model=config.deepseek.model, + llm = ZaiLLM( + api_url=config.zai.base_url, + api_key=config.zai.api_key, + model=config.zai.model, ) self.agent_tools = AgentTools(config, database, exchange) self.aggregator = Aggregator( llm, ToolManager(self.agent_tools.tools()), master_prompt=_master_prompt, - max_tool_rounds=config.deepseek.max_tool_rounds, - thinking=config.deepseek.thinking, + max_tool_rounds=config.zai.max_tool_rounds, + thinking=config.zai.thinking, ) async def run(self, trigger: str) -> str: diff --git a/src/llm_trade_bot/cli.py b/src/llm_trade_bot/cli.py index d540316..12adaaf 100644 --- a/src/llm_trade_bot/cli.py +++ b/src/llm_trade_bot/cli.py @@ -11,7 +11,7 @@ from llm_trade_bot.service import TradeBotService def main() -> None: - parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo or Binance Testnet") + parser = argparse.ArgumentParser(description="Autonomous GLM trader for Bybit Demo or Binance Testnet") parser.add_argument("--config", default="config.toml", help="Path to TOML configuration") parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR")) args = parser.parse_args() diff --git a/src/llm_trade_bot/config.py b/src/llm_trade_bot/config.py index 7d3d3b8..0ae441e 100644 --- a/src/llm_trade_bot/config.py +++ b/src/llm_trade_bot/config.py @@ -13,10 +13,10 @@ class ConfigError(ValueError): @dataclass(frozen=True, slots=True) -class DeepSeekConfig: +class ZaiConfig: api_key: str - base_url: str = "https://api.deepseek.com" - model: str = "deepseek-flash" + base_url: str = "https://api.z.ai/api/paas/v4" + model: str = "glm-4.6" thinking: bool = True max_tool_rounds: int = 16 @@ -99,7 +99,7 @@ class StorageConfig: @dataclass(frozen=True, slots=True) class AppConfig: - deepseek: DeepSeekConfig + zai: ZaiConfig bybit: BybitConfig | None telegram: TelegramConfig brave: BraveConfig @@ -121,7 +121,7 @@ def load_config(path: str | Path) -> AppConfig: except tomllib.TOMLDecodeError as error: raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error - deepseek_raw = _section(raw, "deepseek") + zai_raw = _section(raw, "zai") exchange_raw = raw.get("exchange") if exchange_raw is None: if not isinstance(raw.get("bybit"), dict): @@ -142,15 +142,15 @@ def load_config(path: str | Path) -> AppConfig: risk_raw = _section(raw, "risk") storage_raw = _section(raw, "storage") - deepseek = DeepSeekConfig( - api_key=_secret(deepseek_raw, "api_key"), - base_url=_string(deepseek_raw, "base_url", "https://api.deepseek.com").rstrip("/"), - model=_string(deepseek_raw, "model", "deepseek-flash"), - thinking=_boolean(deepseek_raw, "thinking", True), - max_tool_rounds=_integer(deepseek_raw, "max_tool_rounds", 16, minimum=1, maximum=256), + zai = ZaiConfig( + api_key=_secret(zai_raw, "api_key"), + base_url=_string(zai_raw, "base_url", "https://api.z.ai/api/paas/v4").rstrip("/"), + model=_string(zai_raw, "model", "glm-4.6"), + thinking=_boolean(zai_raw, "thinking", True), + max_tool_rounds=_integer(zai_raw, "max_tool_rounds", 16, minimum=1, maximum=256), ) - if deepseek.base_url != "https://api.deepseek.com": - raise ConfigError("deepseek.base_url must be https://api.deepseek.com") + if zai.base_url != "https://api.z.ai/api/paas/v4": + raise ConfigError("zai.base_url must be https://api.z.ai/api/paas/v4") bybit: BybitConfig | None = None binance: BinanceConfig | None = None @@ -228,7 +228,7 @@ def load_config(path: str | Path) -> AppConfig: max_notes=_integer(storage_raw, "max_notes", 50, minimum=1, maximum=1000), max_notes_chars=_integer(storage_raw, "max_notes_chars", 20_000, minimum=100, maximum=200_000), ) - return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance) + return AppConfig(zai, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance) def _reject_binance_endpoint_overrides(section: dict[str, Any]) -> None: diff --git a/src/llm_trade_bot/zai_llm.py b/src/llm_trade_bot/zai_llm.py new file mode 100644 index 0000000..e3d662d --- /dev/null +++ b/src/llm_trade_bot/zai_llm.py @@ -0,0 +1,148 @@ +"""z.ai adapter for the provider-neutral micro-agents interface.""" + +from __future__ import annotations + +from typing import Any + +import orjson +from micro_agents import OpenAIBasedLLM +from micro_agents.conversation import ( + Conversation, + LlmMessage, + Message, + ReasoningMessage, + ToolCallMessage, + ToolCallResponseMessage, + UserMessage, +) +import micro_agents.tools as tools_package + + +class ZaiLLM(OpenAIBasedLLM): + """Use z.ai's OpenAI-compatible Chat Completions endpoint. + + ``OpenAIBasedLLM`` in micro-agents targets OpenAI's Responses API, which + z.ai does not expose. This adapter retains its tool-schema inference while + translating the agent conversation to the Chat Completions format. + """ + + async def ask( + self, + conversation: Conversation, + tools: list[tools_package.Tool], + *, + instructions: str | None = None, + thinking: bool | None = None, + ) -> list[Message]: + extra_body: dict[str, object] | None = None + if thinking is not None: + extra_body = {"thinking": {"type": "enabled" if thinking else "disabled"}} + response = await self._ai.chat.completions.create( + model=self._model, + messages=self._serialize_conversation(conversation, instructions), + tools=self._serialize_chat_tools(tools), + stream=False, + extra_body=extra_body, + ) + if not response.choices: + raise ValueError("z.ai returned no choices") + message = response.choices[0].message + tool_calls = message.tool_calls or [] + out: list[Message] = [] + + # GLM returns this field when thinking is enabled. Keeping it in the + # conversation lets the next call include it with the tool invocation. + reasoning = getattr(message, "reasoning_content", None) + if isinstance(reasoning, str) and reasoning: + out.append(ReasoningMessage(reasoning)) + + # A Chat Completions response with tool calls is represented by one + # assistant message. The content is normally empty in that case; do + # not create a second assistant message before the tool-call message. + if not tool_calls and isinstance(message.content, str) and message.content: + out.append(LlmMessage(message.content)) + for call in tool_calls: + function = call.function + if not isinstance(call.id, str) or not isinstance(function.name, str): + raise ValueError("z.ai returned an invalid function call") + try: + arguments = orjson.loads(function.arguments) + except orjson.JSONDecodeError as error: + raise ValueError("z.ai returned invalid JSON function arguments") from error + if not isinstance(arguments, dict): + raise ValueError("z.ai function-call arguments must be an object") + out.append(ToolCallMessage(call.id, function.name, arguments)) + return out + + def _serialize_chat_tools(self, tools: list[tools_package.Tool]) -> list[dict[str, object]]: + result: list[dict[str, object]] = [] + for tool in tools: + schema = self._serialize_tool(tool) + result.append( + { + "type": "function", + "function": { + "name": schema["name"], + "description": schema["description"], + "parameters": schema["parameters"], + }, + } + ) + return result + + @staticmethod + def _serialize_conversation(conversation: Conversation, instructions: str | None) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + if instructions: + messages.append({"role": "system", "content": instructions}) + + items = conversation.messages + index = 0 + pending_reasoning: str | None = None + while index < len(items): + item = items[index] + if isinstance(item, ReasoningMessage): + pending_reasoning = item.text() + index += 1 + continue + if isinstance(item, UserMessage): + messages.append({"role": "user", "content": item.message()}) + elif isinstance(item, LlmMessage): + assistant: dict[str, Any] = {"role": "assistant", "content": item.message()} + if pending_reasoning: + assistant["reasoning_content"] = pending_reasoning + pending_reasoning = None + messages.append(assistant) + elif isinstance(item, ToolCallMessage): + calls: list[dict[str, object]] = [] + while index < len(items) and isinstance(items[index], ToolCallMessage): + call = items[index] + calls.append( + { + "id": call.unique_id(), + "type": "function", + "function": { + "name": call.tool_name(), + "arguments": orjson.dumps(call.arguments()).decode("utf-8"), + }, + } + ) + index += 1 + assistant = {"role": "assistant", "content": None, "tool_calls": calls} + if pending_reasoning: + assistant["reasoning_content"] = pending_reasoning + pending_reasoning = None + messages.append(assistant) + continue + elif isinstance(item, ToolCallResponseMessage): + messages.append( + { + "role": "tool", + "tool_call_id": item.unique_id(), + "content": item.text(), + } + ) + else: + raise ValueError(f"Unknown type of message {type(item).__name__}") + index += 1 + return messages diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py index 44b0d96..8fc26d1 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -10,7 +10,7 @@ from llm_trade_bot.config import ( AppConfig, BraveConfig, BybitConfig, - DeepSeekConfig, + ZaiConfig, PromptsConfig, RiskConfig, ScannerConfig, @@ -48,7 +48,7 @@ class FakeBybit: def make_config(database_path: Path) -> AppConfig: return AppConfig( - DeepSeekConfig("key"), + ZaiConfig("key"), BybitConfig("key", "secret"), TelegramConfig(1, "hash", "token", frozenset({1})), BraveConfig("key"), diff --git a/tests/test_config.py b/tests/test_config.py index eef1e6c..8d34984 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,8 +5,8 @@ import pytest from llm_trade_bot.config import ConfigError, load_config CONFIG = """ -[deepseek] -api_key = "deepseek-key" +[zai] +api_key = "zai-key" [bybit] api_key = "bybit-key" api_secret = "bybit-secret" @@ -51,7 +51,7 @@ def write_config(tmp_path: Path, content: str = CONFIG) -> Path: def test_loads_and_resolves_database_path(tmp_path: Path): path = write_config(tmp_path) config = load_config(path) - assert config.deepseek.model == "deepseek-flash" + assert config.zai.model == "glm-4.6" assert config.telegram.admin_user_ids == frozenset({42}) assert config.storage.database_path == tmp_path / "state.db" assert config.prompts.file_path == tmp_path / "prompts.md" @@ -63,8 +63,8 @@ def test_rejects_risk_above_hard_limit(tmp_path: Path): load_config(path) -def test_rejects_non_official_deepseek_endpoint(tmp_path: Path): - changed = CONFIG.replace("api_key = \"deepseek-key\"", 'api_key = "deepseek-key"\nbase_url = "https://example.com"', 1) +def test_rejects_non_official_zai_endpoint(tmp_path: Path): + changed = CONFIG.replace("api_key = \"zai-key\"", 'api_key = "zai-key"\nbase_url = "https://example.com"', 1) path = write_config(tmp_path, changed) with pytest.raises(ConfigError, match="base_url"): load_config(path) diff --git a/tests/test_zai_llm.py b/tests/test_zai_llm.py new file mode 100644 index 0000000..339286e --- /dev/null +++ b/tests/test_zai_llm.py @@ -0,0 +1,34 @@ +from micro_agents.conversation import Conversation, ReasoningMessage, ToolCallMessage, ToolCallResponseMessage, UserMessage + +from llm_trade_bot.zai_llm import ZaiLLM + + +def test_serializes_glm_tool_round_in_chat_completions_format(): + conversation = Conversation( + [ + UserMessage("analyse BTC"), + ReasoningMessage("I need market data."), + ToolCallMessage("call-1", "get_market_data", {"symbol": "BTCUSDT"}), + ToolCallResponseMessage("call-1", '{"last_price":"100000"}'), + ] + ) + + messages = ZaiLLM._serialize_conversation(conversation, "Follow the risk limits.") + + assert messages == [ + {"role": "system", "content": "Follow the risk limits."}, + {"role": "user", "content": "analyse BTC"}, + { + "role": "assistant", + "content": None, + "reasoning_content": "I need market data.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "get_market_data", "arguments": '{"symbol":"BTCUSDT"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "content": '{"last_price":"100000"}'}, + ]