Merge remote-tracking branch 'origin/master' into feat/blocking-interactions

This commit is contained in:
fdc310
2026-10-31 03:17:49 +08:00
26 changed files with 1821 additions and 15 deletions
+1
View File
@@ -31,6 +31,7 @@ bard.json
!.pre-commit-config.yaml
!components.yaml
!/docker-compose.yaml
docker/data
data/labels/instance_id.json
.DS_Store
/data
@@ -96,4 +96,4 @@ The full regression also found Windows portability problems in the skills toolin
2. **Bounded stream pressure can fail long responses.** Two recovery runs emitted thinking and then `Streaming action consumer is too slow; response buffer full`; later recovery and six-tool runs succeeded. The error originates in the SDK's existing 128-frame response queue and is preserved in the UI. It is not a missing-tool-result or 30-second HTTP timeout. Full end-to-end flow control under sustained overload remains open; queue limits were not removed to conceal the failure.
3. Mock results verify debug behavior, not real platform credentials, permissions, delivery, or every adapter/event Cartesian combination. Already launched external tool termination timing was not independently measured. A development-branch push does not mean these remaining production release gates passed.
For reproduction, use the retained test Agent, the Mock examples in [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md), and the original welcome Agent. Local diagnostic traces and fixture artifacts are excluded from Git; they contain model responses and local runtime state. This report contains outcomes rather than raw provider reasoning or secrets.
For reproduction, use the retained test Agent, the Mock examples in [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md), and the original welcome Agent. Local diagnostic traces and fixture artifacts are excluded from Git; they contain model responses and local runtime state. This report contains outcomes rather than raw provider reasoning or secrets.
@@ -3,7 +3,7 @@
本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。
- SDK ↔ Host 的协议数据结构(`RunnerContext`、`RunnerManifest`、`RunnerResult`、`RunnerAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。
- 测试执行入口和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`RunnerDescriptor`)不属于 SDK 协议字段。
## 1. 目标
@@ -1,6 +1,6 @@
# 官方 Runner 插件迁移计划
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。
@@ -110,7 +110,7 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/
## 7. Code-agent harness runner 当前形态
外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `RunnerResult`。本地 smoke 验收入口与记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。
外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `RunnerResult`。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
当前形态:
@@ -2,7 +2,7 @@
本文档是 LangBot Host 与插件 SDK / Runtime / Runner 之间协议合同的**唯一规范来源(single source of truth)**。
- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
@@ -9,7 +9,7 @@
## 文档维护原则(单一事实源)
- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。
- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。
- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。
@@ -54,7 +54,7 @@
EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完整 Stage 链;当 Pipeline 的 AI Stage 调用 runner 时,`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Agent 目标则直接从自己的持久配置构造 binding。两条路径可以复用同一套 Runner Host capabilities,但 Pipeline 本身不会被投影或持久化为 Agent。
下一轮测试路径、状态定义和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。
下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
## 术语表
@@ -87,7 +87,7 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完
| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 |
| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 |
| [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md) | 当前事件工具、平台动作和普通工具的配置、投射及执行授权。 |
| [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md) | Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
## 工作拆分
@@ -2,7 +2,7 @@
本文档记录 Runner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `Runner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。
> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
>
> 实现状态说明:本文描述的是 Runtime Control Plane v2 的目标能力和分阶段落地建议。当前 Runner 插件化主线已经具备 event-first context、run-scoped authorization、EventLog / Transcript / State / sandbox 文件等 Host capability,并已落地持久 `AgentRun` / `AgentRunEvent` ledger、run control actions、最小 runtime heartbeat/claim lease 和 admin reconcile 原语。完整 Agent Platform 产品形态、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍未完成。当前实现状态以 [STATUS.md](./STATUS.md) 为准。
@@ -1,6 +1,6 @@
# Runner Pluginization Status
本文档是 `dev-docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
本文档是 `dev-docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
状态快照日期:2026-09-05。代码基线为 LangBot `dev/4.11.x` / `a4d36aa2d` 和 SDK `dev/4.11.x` / `f1da058`;本文是本地检视快照,不代表远端最新状态或正式发布批准。
@@ -0,0 +1,72 @@
# LINE EBA Adapter
## Structure
LINE is migrated into `src/langbot/pkg/platform/adapters/line/` with the standard EBA adapter layout:
- `adapter.py` owns lifecycle, unified webhook handling, signature verification, listener dispatch, and outbound send/reply.
- `event_converter.py` maps LINE webhook events to `message.received`, `message.deleted`, group membership events, and `platform.specific`.
- `message_converter.py` maps common `MessageChain` components to LINE message objects and maps inbound LINE text/image/voice payloads back to EBA components.
- `api_impl.py` implements the optional common APIs against the LINE Messaging API.
- `platform_api.py` declares the safe LINE-specific pass-through actions.
- `manifest.yaml` declares `line-omni`.
The legacy `src/langbot/pkg/platform/sources/line.py` adapter is kept unchanged.
Request handling reuses the proven behaviour of the legacy adapter: `WebhookParser` verifies `X-Line-Signature`, the webhook acknowledges immediately, and each event is converted and dispatched in a background task.
## Configuration
| Field | Required | Notes |
|-------|----------|-------|
| `webhook_url` | No | Generated by LangBot. Paste it into the LINE Developers console and enable "Use webhook". |
| `channel_access_token` | Yes | Long-lived channel access token, used for all outbound calls. |
| `channel_secret` | Yes | Channel secret, used to verify `X-Line-Signature`. |
`bot_account_id` is resolved through `get_bot_info` on startup and is used to drop the adapter's own messages.
## Events
| Event | Notes |
|-------|-------|
| `message.received` | Text, image, voice, and other message types. Group and room chats are mapped to group chat identity. LINE mentions become `At` components. |
| `message.deleted` | Emitted for LINE `unsend` events. |
| `group.member_joined` | Emitted for `memberJoined`; the first listed member is the event subject. |
| `group.member_left` | Emitted for `memberLeft`. |
| `bot.invited_to_group` | Emitted when the Official Account joins a group or room (`join`). |
| `bot.removed_from_group` | Emitted when the Official Account leaves (`leave`). |
| `platform.specific` | `line.follow`, `line.unfollow`, `line.postback`, `line.beacon`, and any other LINE event type, with `chat_id` in `data`. |
Legacy pipeline bindings still receive `FriendMessage` / `GroupMessage`; the adapter converts the unified event for them.
## Common APIs
Required:
- `send_message`
- `reply_message`
Optional:
- `get_user_info`
- `get_group_info`
- `leave_group`
- `call_platform_api`
`reply_message` uses the event's reply token and falls back to `push_message` when the token is rejected or already used, so multi-message answers still arrive.
## Platform APIs
| Action | Notes |
|--------|-------|
| `get_bot_info` | Returns the Official Account profile. |
| `get_profile` | Calls `get_profile` for a user id. |
| `get_group_summary` | Calls `get_group_summary` for a group id. |
## Known Limits
- LINE has no group member listing API, so `get_group_member_list` raises `NotSupportedError`.
- Outbound images require a public URL: the Messaging API fetches `originalContentUrl`, so a base64-only `Image` component raises `NotSupportedError` instead of sending nothing.
- LINE has no message edit API; `edit_message` raises `NotSupportedError`. Streaming output is not supported.
- Requests are capped at five messages per call and 5000 characters per text message; longer chains are split into multiple requests.
- Offline coverage lives in `tests/unit_tests/platform/test_line_eba_adapter.py`. A real channel (webhook delivery, reply tokens, media download) still needs verification against a live LINE Official Account.
+50
View File
@@ -0,0 +1,50 @@
# MiniMax requester compatibility
Implementation reference: `https://platform.minimax.io/docs/api-reference/text-openai-api.md`
(checked 2026-10-30).
## Discovery and saved configurations
New MiniMax configurations use `https://api.minimax.io/v1`. Saved endpoints and
manually selected model identifiers are not migrated or rewritten.
Direct discovery recommends MiniMax-M3.1-Flash followed by MiniMax-M3, including
when the direct service has no model-list endpoint (404). Authentication,
rate-limit, and server errors still fail the scan. Documented capabilities and
the 1,000,000-token context are defaults for these direct recommendations;
advertised context lengths are retained.
OpenRouter discovery never invents routes or replaces advertised metadata.
All advertised Flash variants precede M3. Only known legacy MiniMax text
families (M1/M2, MiniMax-01, abab) are removed from discovery; non-text entries,
other vendors, custom identifiers, and future model families remain available.
This filtering does not prevent saved/manual legacy models from being invoked.
## Thinking and conversation history
- Flash supports provider defaults, adaptive thinking (`enabled`), and
`low`, `medium`, `high`, `xhigh`, `max` effort; disabling is rejected locally.
- M3 supports provider defaults, adaptive thinking, and disabled thinking.
- The default policy sends no additional thinking, admission-tier, or
output-format parameters. Gateway requests keep the existing gateway protocol.
- `remove_think` changes display text, not the assistant history sent back to
MiniMax. Both `reasoning_content` and the original inline `<think>` content are
retained, including when the next M3 request disables thinking.
`minimax_raw_content` is internal provider metadata. Non-streaming responses
store their original content before display filtering. Streaming responses
buffer raw content and emit one complete snapshot at the finish event, or at
normal iterator exhaustion when the finish event is absent. This matches Local
Agent's opaque-field replacement semantics: only `reasoning_content` is a
concatenated delta there. Sending raw-content deltas would silently retain only
the last fragment. The internal field is removed during request conversion and
is not replayed as content when the user switches to another provider.
## Regression coverage
`tests/unit_tests/provider/test_minimax_protocol.py` covers real locked SDK
serialization against mocked HTTP/SSE, saved endpoints and identifiers,
thinking validation, image input, discovery failures and metadata, streaming
termination, JSON history round trips, tool follow-ups, and provider switching.
It makes no paid inference calls. The provider suite remains the shared-provider
regression check; Python packaging is independent of frontend/runtime deployment.
+4 -2
View File
@@ -1,7 +1,5 @@
# Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
version: "3"
services:
langbot_plugin_runtime:
@@ -16,6 +14,10 @@ services:
- ./data/plugin-runtime:/app/data/plugin-runtime
ports:
- 5401:5401
# ACP Agent Runner daemon hub. With `daemon-enabled = true` in the plugin
# config, a user-side runner daemon behind NAT connects outward to
# ws://<langbot-public-host>:8766. Harmless when the hub is disabled.
- 8766:8766
restart: on-failure
environment:
- TZ=Asia/Shanghai
@@ -13,6 +13,7 @@ OMNI_ADAPTER_NAMES = frozenset(
'wecombot',
'wecomcs',
'officialaccount',
'line',
}
)
@@ -0,0 +1,258 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import pydantic
import quart
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
ApiClient,
Configuration,
MessagingApi,
MessagingApiBlob,
PushMessageRequest,
ReplyMessageRequest,
)
from linebot.v3.webhook import WebhookParser
from linebot.v3.webhooks import Event
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot.pkg.platform.adapters.line.api_impl import LineAPIMixin
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
from langbot.pkg.platform.adapters.line.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME, MAX_MESSAGES_PER_REQUEST
class LineAdapter(LineAPIMixin, abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""LINE Messaging API adapter in unified webhook mode."""
api_client: typing.Any = pydantic.Field(exclude=True)
bot: typing.Any = pydantic.Field(exclude=True)
blob_api: typing.Any = pydantic.Field(exclude=True)
parser: typing.Any = pydantic.Field(exclude=True)
message_converter: LineMessageConverter = LineMessageConverter()
event_converter: LineEventConverter = LineEventConverter()
config: dict
config_secret: str = ''
bot_uuid: str | None = None
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
background_tasks: set = pydantic.Field(default_factory=set, exclude=True)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
missing_keys = [key for key in ('channel_access_token', 'channel_secret') if not config.get(key)]
if missing_keys:
raise ValueError(f'LINE adapter missing config: {missing_keys}')
configuration = Configuration(access_token=config['channel_access_token'])
api_client = ApiClient(configuration)
bot = MessagingApi(api_client)
blob_api = MessagingApiBlob(api_client)
bot_account_id = config.get('bot_account_id', '') or ''
super().__init__(
config=config,
logger=logger,
bot_account_id=bot_account_id,
config_secret=config['channel_secret'],
api_client=api_client,
bot=bot,
blob_api=blob_api,
parser=WebhookParser(config['channel_secret']),
listeners={},
background_tasks=set(),
message_converter=LineMessageConverter(),
event_converter=LineEventConverter(bot_account_id=bot_account_id, blob_api=blob_api),
)
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'message.deleted',
'group.member_joined',
'group.member_left',
'bot.invited_to_group',
'bot.removed_from_group',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_user_info',
'get_group_info',
'leave_group',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
messages = await self.message_converter.yiri2target(message)
for batch in _batches(messages):
request = PushMessageRequest(to=str(target_id), messages=batch)
await asyncio.to_thread(self.bot.push_message, request)
return platform_events.MessageResult(raw={'sent': len(messages), 'target_id': str(target_id)})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
source = await LineEventConverter.yiri2target(message_source)
messages = await self.message_converter.yiri2target(message)
reply_token = str(getattr(source, 'reply_token', '') or '')
chat_id = _source_chat_id(source) or str(getattr(message_source, 'chat_id', '') or '')
for batch in _batches(messages):
if reply_token:
try:
await asyncio.to_thread(
self.bot.reply_message, ReplyMessageRequest(reply_token=reply_token, messages=batch)
)
# A reply token is single-use; later batches of the same turn fall back to push.
reply_token = ''
continue
except Exception:
await self.logger.warning(
f'LINE reply token rejected, falling back to push: {traceback.format_exc()}'
)
reply_token = ''
if not chat_id:
raise NotSupportedError('reply_message:line_missing_target')
await asyncio.to_thread(self.bot.push_message, PushMessageRequest(to=chat_id, messages=batch))
return platform_events.MessageResult(raw={'sent': len(messages)})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, dict(params or {}))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request) -> typing.Any:
signature = request.headers.get('X-Line-Signature')
body = await request.get_data(as_text=True)
if not signature:
await self.logger.warning('LINE webhook rejected: missing X-Line-Signature header')
return quart.Response('Missing X-Line-Signature header', status=400)
try:
events = self.parser.parse(body, signature)
except InvalidSignatureError:
await self.logger.warning(
'LINE webhook rejected: invalid signature, check channel_access_token/channel_secret'
)
return quart.Response('Invalid signature', status=400)
for event in events or []:
task = asyncio.create_task(self._dispatch_native_event(event))
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
# Acknowledge immediately; LINE retries any non-200 answer.
return quart.Response('OK', status=200)
async def _dispatch_native_event(self, event: Event) -> None:
try:
if self.bot_account_id and _sender_id(event) == self.bot_account_id:
return
eba_event = await self.event_converter.target2yiri(event)
if eba_event is None:
return
if isinstance(eba_event, platform_events.MessageReceivedEvent):
legacy_event = eba_event.to_legacy_event()
listener = self.listeners.get(type(legacy_event))
if listener:
await listener(legacy_event, self)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'LINE native event error: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent) -> None:
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def run_async(self):
if not self.bot_account_id:
try:
info = await asyncio.to_thread(self.bot.get_bot_info)
self.bot_account_id = info.user_id
self.event_converter.bot_account_id = info.user_id
except Exception:
await self.logger.warning(
'LINE adapter could not resolve its account id; self-message filtering stays disabled'
)
await self.logger.info(f'{ADAPTER_NAME} adapter running in unified webhook mode')
while True:
await asyncio.sleep(1)
async def kill(self) -> bool:
for task in list(self.background_tasks):
task.cancel()
self.background_tasks.clear()
await asyncio.to_thread(self.api_client.close)
return True
def _batches(messages: list[typing.Any]) -> list[list[typing.Any]]:
"""Split a message array into LINE's per-request budget."""
return [
messages[index : index + MAX_MESSAGES_PER_REQUEST]
for index in range(0, len(messages), MAX_MESSAGES_PER_REQUEST)
]
def _sender_id(event: Event) -> str:
source = getattr(event, 'source', None)
return str(getattr(source, 'user_id', '') or '')
def _source_chat_id(event: Event) -> str:
"""Chat id of the conversation a LINE event belongs to."""
source = getattr(event, 'source', None)
for attribute in ('group_id', 'room_id', 'user_id'):
value = getattr(source, attribute, '') or ''
if value:
return str(value)
return ''
@@ -0,0 +1,86 @@
from __future__ import annotations
import typing
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class LineAPIMixin:
"""Optional universal APIs; LINE only exposes profile and group summaries."""
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
import asyncio
profile = await asyncio.to_thread(self.bot.get_profile, str(user_id))
return platform_entities.User(
id=profile.user_id,
nickname=profile.display_name,
avatar_url=profile.picture_url or '',
)
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
import asyncio
try:
summary = await asyncio.to_thread(self.bot.get_group_summary, str(group_id))
except Exception as exc:
raise NotSupportedError('get_group_info:group_not_found') from exc
return platform_entities.UserGroup(
id=summary.group_id,
name=summary.group_name,
avatar_url=summary.picture_url or '',
)
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
import asyncio
await asyncio.to_thread(self.bot.leave_group, str(group_id))
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
# LINE does not expose a group member listing API.
raise NotSupportedError('get_group_member_list')
async def get_group_list(self) -> list[platform_entities.UserGroup]:
raise NotSupportedError('get_group_list')
async def get_friend_list(self) -> list[platform_entities.User]:
raise NotSupportedError('get_friend_list')
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raise NotSupportedError('forward_message')
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
@@ -0,0 +1,10 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -0,0 +1,169 @@
from __future__ import annotations
import typing
from linebot.v3.webhooks import (
BeaconEvent,
Event,
FollowEvent,
GroupSource,
JoinEvent,
LeaveEvent,
MemberJoinedEvent,
MemberLeftEvent,
MessageEvent,
PostbackEvent,
RoomSource,
UnfollowEvent,
UnsendEvent,
UserSource,
)
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME
def _timestamp(event: Event) -> float:
try:
return float(event.timestamp) / 1000.0
except (AttributeError, TypeError, ValueError):
return 0.0
def _chat_identity(event: Event) -> tuple[platform_entities.ChatType, str, typing.Any, str]:
"""Return chat type, chat id, group entity and sender id for a LINE event source."""
source = event.source
if isinstance(source, GroupSource):
return (
platform_entities.ChatType.GROUP,
source.group_id,
platform_entities.UserGroup(id=source.group_id, name=source.group_id),
source.user_id or source.group_id,
)
if isinstance(source, RoomSource):
# LINE rooms are multi-user chats; treat them as group chats keyed by room id.
return (
platform_entities.ChatType.GROUP,
source.room_id,
platform_entities.UserGroup(id=source.room_id, name=source.room_id),
source.user_id or source.room_id,
)
user_id = source.user_id if isinstance(source, UserSource) else ''
return platform_entities.ChatType.PRIVATE, user_id, None, user_id
class LineEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Convert LINE webhook events into unified EBA events and legacy message events."""
def __init__(self, bot_account_id: str = '', blob_api: typing.Any = None):
self.bot_account_id = bot_account_id
self.blob_api = blob_api
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2yiri(self, event: Event) -> typing.Optional[platform_events.EBAEvent]:
"""Convert one LINE webhook event into a unified event; None when unsupported."""
common = dict(
adapter_name=ADAPTER_NAME,
timestamp=_timestamp(event),
source_platform_object=event,
)
chat_type, chat_id, group, sender_id = _chat_identity(event)
chat = dict(
chat_type=chat_type,
chat_id=chat_id,
group=group,
)
if isinstance(event, MessageEvent):
return platform_events.MessageReceivedEvent(
**common,
**chat,
message_id=str(event.message.id),
message_chain=await LineMessageConverter.target2yiri(
event, blob_api=self.blob_api, bot_account_id=self.bot_account_id
),
sender=platform_entities.User(id=sender_id, nickname=sender_id),
)
if isinstance(event, UnsendEvent):
return platform_events.MessageDeletedEvent(
**common,
**chat,
message_id=str(event.unsend.message_id),
operator=platform_entities.User(id=sender_id) if sender_id else None,
)
if isinstance(event, FollowEvent):
return platform_events.PlatformSpecificEvent(
**common, action='line.follow', data={**_event_payload(event), 'chat_id': chat_id}
)
if isinstance(event, UnfollowEvent):
return platform_events.PlatformSpecificEvent(
**common, action='line.unfollow', data={**_event_payload(event), 'chat_id': chat_id}
)
if isinstance(event, JoinEvent):
return platform_events.BotInvitedToGroupEvent(
**common,
group=platform_entities.UserGroup(id=chat_id, name=chat_id),
)
if isinstance(event, LeaveEvent):
return platform_events.BotRemovedFromGroupEvent(
**common,
group=platform_entities.UserGroup(id=chat_id, name=chat_id),
)
if isinstance(event, MemberJoinedEvent):
member = _first_member(event.joined.members, chat_id)
return platform_events.MemberJoinedEvent(**common, group=group, member=member)
if isinstance(event, MemberLeftEvent):
member = _first_member(event.left.members, chat_id)
return platform_events.MemberLeftEvent(**common, group=group, member=member)
if isinstance(event, PostbackEvent):
return platform_events.PlatformSpecificEvent(
**common,
action='line.postback',
data={
'data': event.postback.data,
'chat_type': chat_type.value,
'chat_id': chat_id,
'sender_id': sender_id,
**({'params': event.postback.params} if event.postback.params else {}),
},
)
if isinstance(event, BeaconEvent):
return platform_events.PlatformSpecificEvent(
**common,
action='line.beacon',
data={'hwid': event.beacon.hwid, 'type': event.beacon.type, 'chat_id': chat_id},
)
# Remaining LINE event types stay visible to event processors without a lossy mapping.
action = getattr(event, 'type', '') or type(event).__name__
return platform_events.PlatformSpecificEvent(
**common, action=f'line.{action}', data={**_event_payload(event), 'chat_id': chat_id}
)
async def target2legacy(self, event: Event) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
"""Build the legacy message event used by pipeline-based bindings."""
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
return eba_event.to_legacy_event()
def _event_payload(event: Event) -> dict:
to_dict = getattr(event, 'to_dict', None)
if callable(to_dict):
return dict(to_dict())
return {}
def _first_member(members: typing.Any, fallback_id: str) -> platform_entities.User:
"""Member arrays carry only user ids; the first entry is the subject of the event."""
for member in members or []:
user_id = getattr(member, 'user_id', '') or ''
if user_id:
return platform_entities.User(id=user_id, nickname=user_id)
return platform_entities.User(id=fallback_id, nickname=fallback_id)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,92 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: line-omni
label:
en_US: LINE
zh_Hans: LINE
zh_Hant: LINE
description:
en_US: LINE adapter with event-driven orchestration, receiving messages through the unified webhook.
zh_Hans: LINE 适配器,支持事件驱动编排,通过统一 Webhook 接收消息。
zh_Hant: LINE 適配器,支援事件驅動編排,透過統一 Webhook 接收訊息。
icon: line.png
spec:
categories:
- popular
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/line
en: https://langbot.app/docs/en/usage/platforms/line
ja: https://langbot.app/docs/ja/usage/platforms/line
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL into the LINE Developers console webhook settings and enable "Use webhook".
zh_Hans: 将此地址填入 LINE Developers 控制台的 Webhook 设置,并启用「Use webhook」。
zh_Hant: 將此位址填入 LINE Developers 主控台的 Webhook 設定,並啟用「Use webhook」。
type: webhook-url
required: false
default: ""
- name: channel_access_token
label:
en_US: Channel Access Token
zh_Hans: 频道访问令牌
zh_Hant: 頻道存取權杖
description:
en_US: Long-lived channel access token used to send messages.
zh_Hans: 用于发送消息的长期频道访问令牌。
zh_Hant: 用於傳送訊息的長期頻道存取權杖。
type: string
required: true
default: ""
- name: channel_secret
label:
en_US: Channel Secret
zh_Hans: 频道密钥
zh_Hant: 頻道密鑰
description:
en_US: Channel secret used to verify the X-Line-Signature of incoming webhooks.
zh_Hans: 用于校验入站 Webhook 的 X-Line-Signature 的频道密钥。
zh_Hant: 用於校驗入站 Webhook 的 X-Line-Signature 的頻道密鑰。
type: string
required: true
default: ""
supported_events:
- message.received
- message.deleted
- group.member_joined
- group.member_left
- bot.invited_to_group
- bot.removed_from_group
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_user_info
- get_group_info
- leave_group
- call_platform_api
platform_specific_apis:
- action: get_bot_info
description: { en_US: "Return the LINE Official Account profile", zh_Hans: "返回 LINE 官方账号资料" }
- action: get_profile
description: { en_US: "Call the LINE get profile API for a user", zh_Hans: "调用 LINE 用户资料接口" }
- action: get_group_summary
description: { en_US: "Call the LINE group summary API", zh_Hans: "调用 LINE 群组摘要接口" }
execution:
python:
path: ./adapter.py
attr: LineAdapter
@@ -0,0 +1,158 @@
from __future__ import annotations
import base64
import datetime
import typing
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from linebot.v3.messaging import ImageMessage, TextMessage
from linebot.v3.webhooks import (
AudioMessageContent,
FileMessageContent,
ImageMessageContent,
LocationMessageContent,
MessageEvent,
StickerMessageContent,
TextMessageContent,
UserMentionee,
VideoMessageContent,
)
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
from langbot.pkg.platform.adapters.line.types import MAX_LINE_MEDIA_BYTES, MAX_TEXT_LENGTH
# LINE stores inbound images as JPEG and the Blob API does not return the media type.
IMAGE_MIME = 'image/jpeg'
def _validate_media(content: bytes) -> bytes:
"""Reject inbound media above the LINE download cap."""
if len(content) > MAX_LINE_MEDIA_BYTES:
raise ValueError(f'LINE media exceeds the {MAX_LINE_MEDIA_BYTES}-byte limit')
return content
def _split_text(text: str) -> list[str]:
"""Split a text body into LINE-sized chunks so long answers still send."""
return [text[index : index + MAX_TEXT_LENGTH] for index in range(0, len(text), MAX_TEXT_LENGTH)]
def _message_datetime(event: MessageEvent) -> datetime.datetime:
try:
return datetime.datetime.fromtimestamp(float(event.timestamp) / 1000.0)
except (AttributeError, TypeError, ValueError):
return datetime.datetime.now()
def _text_components(text: str, mention: typing.Any, bot_account_id: str) -> list[platform_message.MessageComponent]:
"""Map LINE mention positions onto At components, as the legacy adapter does."""
if not mention or not mention.mentionees:
return [platform_message.Plain(text=text)] if text else []
components: list[platform_message.MessageComponent] = []
segments = sorted(
(mentionee.index, mentionee.index + mentionee.length, mentionee) for mentionee in mention.mentionees
)
cursor = 0
for start, end, mentionee in segments:
if start < cursor:
start, end = cursor, min(end, len(text))
if start < cursor or end <= start or end > len(text):
continue
if start > cursor:
components.append(platform_message.Plain(text=text[cursor:start]))
token = text[start:end]
if isinstance(mentionee, UserMentionee):
target = bot_account_id if mentionee.is_self else (mentionee.user_id or token)
else:
target = token
components.append(platform_message.At(target=str(target), display=token.lstrip('@')))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
async def _download_content(blob_api: typing.Any, message_id: str) -> bytes:
"""Fetch inbound media through the LINE Blob API without blocking the event loop."""
import asyncio
if blob_api is None:
raise NotSupportedError('line:media_download')
return await asyncio.to_thread(blob_api.get_message_content, message_id)
class LineMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Convert between LangBot message chains and LINE Messaging API messages."""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> list[typing.Any]:
"""Build the LINE message array for one push/reply request."""
messages: list[typing.Any] = []
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text or ''))
elif isinstance(component, platform_message.At):
messages.append(TextMessage(text=f'@{component.display or component.target}'))
elif isinstance(component, platform_message.AtAll):
messages.append(TextMessage(text='@All'))
elif isinstance(component, platform_message.Image):
url = component.url or ''
if not url:
# LINE fetches outgoing images from a public URL; inline base64 is not supported.
raise NotSupportedError('send_message:line_image_requires_public_url')
messages.append(ImageMessage(original_content_url=url, preview_image_url=url))
elif isinstance(component, platform_message.Quote):
if component.origin:
messages.extend(await LineMessageConverter.yiri2target(component.origin))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
messages.extend(await LineMessageConverter.yiri2target(node.message_chain))
elif isinstance(component, platform_message.Unknown):
if component.text:
messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text))
else:
raise NotSupportedError(f'send_message:line_unsupported_component:{type(component).__name__}')
return messages
@staticmethod
async def target2yiri(
event: MessageEvent,
blob_api: typing.Any = None,
bot_account_id: str = '',
) -> platform_message.MessageChain:
"""Build a LangBot message chain from a LINE message event."""
message = event.message
components: list[platform_message.MessageComponent] = [
platform_message.Source(
id=str(getattr(message, 'id', '') or event.webhook_event_id), time=_message_datetime(event)
)
]
if isinstance(message, TextMessageContent):
components.extend(_text_components(message.text, getattr(message, 'mention', None), bot_account_id))
elif isinstance(message, ImageMessageContent):
encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii')
components.append(platform_message.Image(base64=f'data:{IMAGE_MIME};base64,{encoded}'))
elif isinstance(message, AudioMessageContent):
encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii')
components.append(platform_message.Voice(base64=encoded, length=int(getattr(message, 'duration', 0) or 0)))
elif isinstance(message, VideoMessageContent):
components.append(platform_message.Unknown(text='[video]'))
elif isinstance(message, StickerMessageContent):
components.append(platform_message.Unknown(text='[sticker]'))
elif isinstance(message, FileMessageContent):
components.append(platform_message.Unknown(text=f'[file] {getattr(message, "file_name", "")}'.strip()))
elif isinstance(message, LocationMessageContent):
components.append(
platform_message.Unknown(
text=f'[location] {getattr(message, "title", "")} {getattr(message, "address", "")}'.strip()
)
)
else:
components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]'))
if len(components) == 1:
components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]'))
return platform_message.MessageChain(components)
@@ -0,0 +1,51 @@
from __future__ import annotations
import typing
async def get_bot_info(adapter, params: dict) -> dict:
import asyncio
info = await asyncio.to_thread(adapter.bot.get_bot_info)
return {
'user_id': info.user_id,
'basic_id': info.basic_id,
'display_name': info.display_name,
'chat_mode': info.chat_mode,
}
async def get_profile(adapter, params: dict) -> dict:
user_id = str(params.get('user_id', ''))
if not user_id:
raise ValueError('get_profile requires user_id')
import asyncio
profile = await asyncio.to_thread(adapter.bot.get_profile, user_id)
return {
'user_id': profile.user_id,
'display_name': profile.display_name,
'picture_url': profile.picture_url or '',
'status_message': profile.status_message or '',
}
async def get_group_summary(adapter, params: dict) -> dict:
group_id = str(params.get('group_id', ''))
if not group_id:
raise ValueError('get_group_summary requires group_id')
import asyncio
summary = await asyncio.to_thread(adapter.bot.get_group_summary, group_id)
return {
'group_id': summary.group_id,
'group_name': summary.group_name,
'picture_url': summary.picture_url or '',
}
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'get_bot_info': get_bot_info,
'get_profile': get_profile,
'get_group_summary': get_group_summary,
}
@@ -0,0 +1,12 @@
from __future__ import annotations
ADAPTER_NAME = 'line-omni'
# LINE Messaging API limits (push/reply share the same message array budget).
MAX_MESSAGES_PER_REQUEST = 5
# A single text message body is limited to 5000 characters.
MAX_TEXT_LENGTH = 5000
# LINE serves inbound media through the Blob API; keep the same cap as the legacy adapter.
MAX_LINE_MEDIA_BYTES = 10 * 1024 * 1024
@@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.secret_managers.main import get_secret, get_secret_bool
from .. import errors, reasoning, requester
from . import minimax
from ....utils import httpclient, image
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -213,6 +214,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
'bailian-chat-completions': 'qwen',
'doubao-chat-completions': 'doubao',
'mimo-chat-completions': 'mimo',
'minimax-chat-completions': 'minimax',
}
default_config: dict[str, typing.Any] = {
@@ -569,6 +571,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None:
normalized_name = model_name.lower().rsplit('/', 1)[-1]
if family == 'minimax':
return minimax.reasoning_levels(normalized_name)
if family == 'deepseek' and normalized_name.startswith('deepseek-'):
if normalized_name.startswith('deepseek-v4-'):
return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
@@ -728,6 +733,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
raise errors.RequesterError(str(exc), code='invalid_policy') from exc
family = self._reasoning_family(model.model_entity.name, model)
if family == 'minimax':
return minimax.reasoning_parameters(level)
budget = config.get('budget_tokens')
if budget is not None:
if self._uses_openrouter_protocol():
@@ -837,6 +844,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
for m in messages:
msg_dict = m.dict(exclude_none=True)
provider_fields = msg_dict.get('provider_specific_fields')
replay_fields = minimax.take_replay_fields(provider_fields) if isinstance(provider_fields, dict) else {}
if isinstance(provider_fields, dict):
# Stream completion metadata belongs to the response, never to
# replayed chat messages. Keep provider signatures for LiteLLM.
@@ -878,6 +886,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
else:
msg_dict.pop('provider_specific_fields', None)
if msg_dict.get('role') == 'assistant' and reasoning_family == 'minimax':
msg_dict.update(replay_fields)
if isinstance(content, list):
converted_parts = []
for part in content:
@@ -1476,6 +1487,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
content = message_data.get('content', '')
reasoning_content = message_data.get('reasoning_content', None)
thinking_blocks = message_data.get('thinking_blocks')
if self._reasoning_family(model.model_entity.name, model) == 'minimax':
message_data['provider_specific_fields'] = minimax.capture_content(message_data)
if reasoning_content or thinking_blocks:
provider_fields = dict(message_data.get('provider_specific_fields') or {})
if reasoning_content:
@@ -1520,6 +1533,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
reasoning_started = False
reasoning_closed = False
thinking_blocks_state: list[dict[str, typing.Any]] = []
keep_minimax_content = self._reasoning_family(model.model_entity.name, model) == 'minimax'
raw_content_parts: list[str] = []
response = None
async with self._client_args(args) as args:
@@ -1562,6 +1577,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
delta_content = delta.get('content') or ''
reasoning_content = delta.get('reasoning_content') or ''
provider_fields = dict(delta.get('provider_specific_fields') or {})
if keep_minimax_content:
if isinstance(delta.get('content'), str):
raw_content_parts.append(delta['content'])
if finish_reason and raw_content_parts:
provider_fields[minimax.RAW_CONTENT_FIELD] = ''.join(raw_content_parts)
raw_content_parts.clear()
if finish_reason:
# Preserve an explicit provider stop even when the final delta is empty.
provider_fields['finish_reason'] = finish_reason
@@ -1637,6 +1658,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
if raw_content_parts:
yield provider_message.MessageChunk(
role=role,
is_final=True,
provider_specific_fields={minimax.RAW_CONTENT_FIELD: ''.join(raw_content_parts)},
)
if reasoning_started and not reasoning_closed:
yield provider_message.MessageChunk(
role=role,
@@ -1882,8 +1910,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status()
payload = await httpclient.parse_json_response(response)
if self._requester_name() == 'minimax-chat-completions' and response.status_code == 404:
payload = {}
else:
response.raise_for_status()
payload = await httpclient.parse_json_response(response)
models = []
for item in payload.get('data', []):
@@ -1895,6 +1926,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
direct_minimax = self._requester_name() == 'minimax-chat-completions'
if direct_minimax or self._uses_openrouter_protocol():
models = minimax.arrange_catalog(models, direct=direct_minimax)
return {'models': models}
except httpx.HTTPStatusError as e:
@@ -0,0 +1,72 @@
from __future__ import annotations
import re
from typing import Any
RECOMMENDED_MODELS = ('MiniMax-M3.1-Flash', 'MiniMax-M3')
RAW_CONTENT_FIELD = 'minimax_raw_content'
_LEGACY_TEXT_MODEL = re.compile(r'^(minimax-(m[12](?:[.\-]|$)|01(?:[.\-]|$))|abab)')
def reasoning_levels(model_name: str) -> list[str] | None:
name = model_name.lower()
if name == RECOMMENDED_MODELS[0].lower():
return ['provider_default', 'enabled', 'low', 'medium', 'high', 'xhigh', 'max']
if name == RECOMMENDED_MODELS[1].lower():
return ['provider_default', 'enabled', 'disabled']
return None
def reasoning_parameters(level: str) -> dict[str, Any]:
if level == 'provider_default':
return {}
thinking_type = {'enabled': 'adaptive', 'disabled': 'disabled'}.get(level)
if thinking_type is not None:
return {'extra_body': {'thinking': {'type': thinking_type}}}
return {'reasoning_effort': level}
def capture_content(payload: dict[str, Any]) -> dict[str, Any]:
fields = dict(payload.get('provider_specific_fields') or {})
if isinstance(payload.get('content'), str):
fields[RAW_CONTENT_FIELD] = payload['content']
return fields
def take_replay_fields(fields: dict[str, Any]) -> dict[str, Any]:
replay = {}
raw_content = fields.pop(RAW_CONTENT_FIELD, None)
if isinstance(raw_content, str):
replay['content'] = raw_content
if isinstance(fields.get('reasoning_content'), str):
replay['reasoning_content'] = fields['reasoning_content']
return replay
def arrange_catalog(models: list[dict[str, Any]], *, direct: bool) -> list[dict[str, Any]]:
priorities = {name.lower(): position for position, name in enumerate(RECOMMENDED_MODELS)}
def model_name(model: dict[str, Any]) -> str:
name = model['id'].lower()
if not direct:
if not name.startswith('minimax/'):
return ''
name = name.removeprefix('minimax/')
return name.partition(':')[0]
catalog = [
dict(model) for model in models if model['type'] != 'llm' or not _LEGACY_TEXT_MODEL.match(model_name(model))
]
if direct:
present = {model['id'].lower() for model in catalog}
catalog.extend(
{'id': name, 'name': name, 'type': 'llm'} for name in RECOMMENDED_MODELS if name.lower() not in present
)
for model in catalog:
if model_name(model) in priorities:
model.setdefault('context_length', 1_000_000)
model['abilities'] = list(
dict.fromkeys([*model.get('abilities', []), 'func_call', 'vision', 'reasoning'])
)
return sorted(catalog, key=lambda model: priorities.get(model_name(model), len(priorities)))
@@ -15,7 +15,7 @@ spec:
zh_Hans: 基础 URL
type: string
required: true
default: https://api.minimax.chat/v1
default: https://api.minimax.io/v1
- name: timeout
label:
en_US: Timeout
@@ -0,0 +1,464 @@
"""Unit coverage for the LINE Omni adapter (unified webhook mode)."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import pathlib
import pytest
import yaml
from linebot.v3.messaging import PushMessageRequest, ReplyMessageRequest
from linebot.v3.webhook import WebhookParser
from langbot.pkg.platform.adapters.line.adapter import LineAdapter
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
SECRET = 'test-channel-secret'
TOKEN = 'test-channel-access-token'
PLATFORM_ROOT = pathlib.Path(__file__).parents[3] / 'src/langbot/pkg/platform'
class DummyLogger(AbstractEventLogger):
def __init__(self):
self.lines: list[tuple[str, str]] = []
async def info(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('info', str(text)))
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('warning', str(text)))
async def error(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('error', str(text)))
class DummyMessagingApi:
def __init__(self, fail_reply: bool = False):
self.push_requests: list[PushMessageRequest] = []
self.reply_requests: list[ReplyMessageRequest] = []
self.fail_reply = fail_reply
self.leaved_groups: list[str] = []
def push_message(self, request):
self.push_requests.append(request)
return {'sent': len(request.messages)}
def reply_message(self, request):
if self.fail_reply:
raise RuntimeError('reply token expired')
self.reply_requests.append(request)
return {'sent': len(request.messages)}
def get_bot_info(self):
return type(
'BotInfo', (), {'user_id': 'Ubot', 'basic_id': '@bot', 'display_name': 'Bot', 'chat_mode': 'chat'}
)()
def get_profile(self, user_id):
return type(
'Profile',
(),
{'user_id': user_id, 'display_name': 'Alice', 'picture_url': 'https://img/1.png', 'status_message': None},
)()
def get_group_summary(self, group_id):
return type('Summary', (), {'group_id': group_id, 'group_name': 'Team', 'picture_url': None})()
def leave_group(self, group_id):
self.leaved_groups.append(group_id)
class DummyBlobApi:
def __init__(self, content: bytes = b'image-bytes'):
self.content = content
self.requested: list[str] = []
def get_message_content(self, message_id):
self.requested.append(message_id)
return self.content
class DummyApiClient:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
class DummyHeaders(dict):
def get(self, key, default=None):
return dict.get(self, key, default)
class DummyRequest:
def __init__(self, body: str, signature: str | None):
self.headers = DummyHeaders({} if signature is None else {'X-Line-Signature': signature})
self._body = body
async def get_data(self, as_text: bool = False):
return self._body if as_text else self._body.encode()
def sign(body: str, secret: str = SECRET) -> str:
return base64.b64encode(hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()).decode()
def message_event(
*,
text: str = 'hello',
source: dict | None = None,
reply_token: str = 'reply-token-1',
message: dict | None = None,
) -> dict:
return {
'type': 'message',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HWEBHOOKID',
'deliveryContext': {'isRedelivery': False},
'source': source or {'type': 'user', 'userId': 'U-user-1'},
'replyToken': reply_token,
'message': message or {'id': 'msg-1', 'type': 'text', 'text': text, 'quoteToken': 'quote-1'},
}
def webhook_body(*events: dict) -> str:
return json.dumps({'destination': 'Ubot', 'events': list(events)})
def build_adapter(logger: DummyLogger | None = None) -> LineAdapter:
return LineAdapter(
config={'channel_access_token': TOKEN, 'channel_secret': SECRET},
logger=logger or DummyLogger(),
)
class EventSink:
def __init__(self):
self.events: list[platform_events.Event] = []
async def __call__(self, event, adapter):
self.events.append(event)
async def dispatch(adapter: LineAdapter, body: str, signature: str | None = None):
response = await adapter.handle_unified_webhook(
bot_uuid='bot-uuid', path='', request=DummyRequest(body, sign(body) if signature is None else signature)
)
pending = list(adapter.background_tasks)
if pending:
await asyncio_gather(pending)
return response
async def asyncio_gather(tasks):
import asyncio
await asyncio.gather(*tasks, return_exceptions=True)
def parse_events(body: str) -> list:
return WebhookParser(SECRET).parse(body, sign(body))
def test_manifest_is_discoverable_and_keeps_legacy_config():
manifest = yaml.safe_load((PLATFORM_ROOT / 'adapters/line/manifest.yaml').read_text(encoding='utf-8'))
legacy = yaml.safe_load((PLATFORM_ROOT / 'sources/line.yaml').read_text(encoding='utf-8'))
assert manifest['metadata']['name'] == 'line-omni'
assert manifest['execution']['python'] == {'path': './adapter.py', 'attr': 'LineAdapter'}
assert {field['name'] for field in legacy['spec']['config']} <= {
field['name'] for field in manifest['spec']['config']
}
assert manifest['spec']['help_links'] == legacy['spec']['help_links']
assert 'message.received' in manifest['spec']['supported_events']
assert (PLATFORM_ROOT / 'adapters/line' / manifest['metadata']['icon']).is_file()
def test_adapter_requires_credentials():
with pytest.raises(ValueError, match='channel_access_token'):
LineAdapter(config={'channel_secret': SECRET}, logger=DummyLogger())
@pytest.mark.asyncio
async def test_webhook_rejects_missing_and_invalid_signature():
adapter = build_adapter()
body = webhook_body(message_event())
assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, None))).status_code == 400
assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, 'bogus'))).status_code == 400
@pytest.mark.asyncio
async def test_webhook_dispatches_message_received_event():
adapter = build_adapter()
sink = EventSink()
adapter.register_listener(platform_events.MessageReceivedEvent, sink)
response = await dispatch(adapter, webhook_body(message_event(text='hi there')))
assert response.status_code == 200
assert len(sink.events) == 1
event = sink.events[0]
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'line-omni'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'U-user-1'
assert event.message_id == 'msg-1'
assert [type(component) for component in event.message_chain] == [platform_message.Source, platform_message.Plain]
assert event.message_chain[1].text == 'hi there'
@pytest.mark.asyncio
async def test_webhook_serves_legacy_listeners_and_ignores_self_messages():
adapter = build_adapter()
adapter.bot_account_id = 'Ubot'
legacy_sink = EventSink()
adapter.register_listener(platform_events.FriendMessage, legacy_sink)
await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'Ubot'})))
assert legacy_sink.events == []
await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'U-user-9'})))
assert len(legacy_sink.events) == 1
assert isinstance(legacy_sink.events[0], platform_events.FriendMessage)
assert legacy_sink.events[0].sender.id == 'U-user-9'
@pytest.mark.asyncio
async def test_group_message_uses_group_chat_identity():
events = parse_events(
webhook_body(message_event(source={'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-2'}))
)
event = await LineEventConverter().target2yiri(events[0])
assert event.chat_type == platform_entities.ChatType.GROUP
assert event.chat_id == 'C-group'
assert event.group.id == 'C-group'
assert event.sender.id == 'U-user-2'
@pytest.mark.asyncio
async def test_unsend_becomes_message_deleted():
body = webhook_body(
{
'type': 'unsend',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HUNSEND',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'user', 'userId': 'U-user-1'},
'unsend': {'messageId': 'msg-9'},
}
)
event = await LineEventConverter().target2yiri(parse_events(body)[0])
assert isinstance(event, platform_events.MessageDeletedEvent)
assert event.message_id == 'msg-9'
assert event.chat_id == 'U-user-1'
@pytest.mark.asyncio
async def test_follow_and_postback_stay_platform_specific():
follow = webhook_body(
{
'type': 'follow',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HFOLLOW',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'user', 'userId': 'U-user-3'},
'replyToken': 'token',
'follow': {'isUnblocked': True},
}
)
follow_event = await LineEventConverter().target2yiri(parse_events(follow)[0])
assert follow_event.type == 'platform.specific'
assert follow_event.action == 'line.follow'
postback = webhook_body(
{
'type': 'postback',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HPOSTBACK',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-4'},
'replyToken': 'token',
'postback': {'data': 'action=buy'},
}
)
postback_event = await LineEventConverter().target2yiri(parse_events(postback)[0])
assert postback_event.action == 'line.postback'
assert postback_event.data['data'] == 'action=buy'
assert postback_event.data['chat_id'] == 'C-group'
@pytest.mark.asyncio
async def test_member_joined_maps_group_and_member():
body = webhook_body(
{
'type': 'memberJoined',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HJOINED',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'group', 'groupId': 'C-group'},
'replyToken': 'token',
'joined': {'members': [{'type': 'user', 'userId': 'U-user-5'}]},
}
)
event = await LineEventConverter().target2yiri(parse_events(body)[0])
assert isinstance(event, platform_events.MemberJoinedEvent)
assert event.group.id == 'C-group'
assert event.member.id == 'U-user-5'
@pytest.mark.asyncio
async def test_text_mention_becomes_at_component_for_the_bot():
body = webhook_body(
message_event(
text='@bot hello',
message={
'id': 'msg-2',
'type': 'text',
'quoteToken': 'quote-2',
'text': '@bot hello',
'mention': {
'mentionees': [
{'index': 0, 'length': 4, 'userId': 'Ubot', 'isSelf': True, 'type': 'user'},
]
},
},
)
)
event = await LineEventConverter(bot_account_id='Ubot').target2yiri(parse_events(body)[0])
at_components = [component for component in event.message_chain if isinstance(component, platform_message.At)]
assert len(at_components) == 1
assert at_components[0].target == 'Ubot'
assert at_components[0].display == 'bot'
@pytest.mark.asyncio
async def test_image_message_is_downloaded_through_the_blob_api():
blob = DummyBlobApi(b'jpeg-bytes')
body = webhook_body(
message_event(
message={
'id': 'msg-3',
'type': 'image',
'quoteToken': 'quote-3',
'contentProvider': {'type': 'line'},
}
)
)
event = await LineEventConverter(blob_api=blob).target2yiri(parse_events(body)[0])
image = event.message_chain[1]
assert isinstance(image, platform_message.Image)
assert image.base64 == f'data:image/jpeg;base64,{base64.b64encode(b"jpeg-bytes").decode()}'
assert blob.requested == ['msg-3']
@pytest.mark.asyncio
async def test_outgoing_chain_builds_line_messages():
chain = platform_message.MessageChain(
[
platform_message.Source(id='msg-1', time=__import__('datetime').datetime.now()),
platform_message.Plain(text='hello'),
platform_message.At(target='U-user-1', display='Alice'),
]
)
messages = await LineMessageConverter.yiri2target(chain)
assert [message.type for message in messages] == ['text', 'text']
assert messages[0].text == 'hello'
assert messages[1].text == '@Alice'
@pytest.mark.asyncio
async def test_outgoing_image_requires_a_public_url():
chain = platform_message.MessageChain([platform_message.Image(base64='ZGly')])
with pytest.raises(NotSupportedError):
await LineMessageConverter.yiri2target(chain)
@pytest.mark.asyncio
async def test_send_message_batches_five_messages_per_request():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
chain = platform_message.MessageChain([platform_message.Plain(text='x'), platform_message.Plain(text='y')] * 3)
result = await adapter.send_message('person', 'U-user-1', chain)
assert [len(request.messages) for request in adapter.bot.push_requests] == [5, 1]
assert all(request.to == 'U-user-1' for request in adapter.bot.push_requests)
assert result.raw == {'sent': 6, 'target_id': 'U-user-1'}
@pytest.mark.asyncio
async def test_reply_uses_reply_token_then_falls_back_to_push():
adapter = build_adapter()
event = await LineEventConverter().target2yiri(parse_events(webhook_body(message_event()))[0])
chain = platform_message.MessageChain([platform_message.Plain(text='pong')])
adapter.bot = DummyMessagingApi()
await adapter.reply_message(event, chain)
assert adapter.bot.reply_requests[0].reply_token == 'reply-token-1'
assert adapter.bot.push_requests == []
adapter.bot = DummyMessagingApi(fail_reply=True)
await adapter.reply_message(event, chain)
assert adapter.bot.push_requests[0].to == 'U-user-1'
@pytest.mark.asyncio
async def test_profile_and_group_apis_use_the_messaging_api():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
user = await adapter.get_user_info('U-user-1')
group = await adapter.get_group_info('C-group')
await adapter.leave_group('C-group')
assert user.id == 'U-user-1'
assert user.nickname == 'Alice'
assert group.name == 'Team'
assert adapter.bot.leaved_groups == ['C-group']
with pytest.raises(NotSupportedError):
await adapter.get_group_member_list('C-group')
@pytest.mark.asyncio
async def test_call_platform_api_dispatches_and_rejects_unknown_actions():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
info = await adapter.call_platform_api('get_bot_info', {})
assert info['user_id'] == 'Ubot'
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('not_a_line_action', {})
@pytest.mark.asyncio
async def test_kill_closes_the_api_client():
adapter = build_adapter()
api_client = DummyApiClient()
adapter.api_client = api_client
assert await adapter.kill() is True
assert api_client.closed is True
assert adapter.background_tasks == set()
@@ -0,0 +1,273 @@
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import yaml
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot.pkg.provider.modelmgr import errors
from langbot.pkg.provider.modelmgr.requesters import litellmchat, minimax
@pytest.fixture
def provider():
manifest = yaml.safe_load(Path(litellmchat.__file__).with_name('minimaxchatcmpl.yaml').read_text())
settings = {option['name']: option['default'] for option in manifest['spec']['config']}
settings.update(custom_llm_provider='openai', requester_name=manifest['metadata']['name'])
tool = {'type': 'function', 'function': {'name': 'lookup', 'parameters': {'type': 'object'}}}
app = SimpleNamespace(tool_mgr=SimpleNamespace(generate_tools_for_openai=AsyncMock(return_value=[tool])))
return litellmchat.LiteLLMRequester(app, settings)
def runtime(provider, name='MiniMax-M3.1-Flash', level='provider_default'):
return SimpleNamespace(
provider=SimpleNamespace(
provider_entity=SimpleNamespace(requester=provider.requester_cfg['requester_name']),
token_mgr=SimpleNamespace(get_token=lambda: 'synthetic-minimax-test'),
),
model_entity=SimpleNamespace(
name=name, abilities=['reasoning'], reasoning_config={'level': level}, extra_args={}
),
)
def completion(message=None):
return {
'id': 'protocol-test',
'created': 1,
'model': 'MiniMax-M3.1-Flash',
'object': 'chat.completion',
'choices': [
{'index': 0, 'message': message or {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}
],
}
@pytest.fixture
def wire(monkeypatch):
captured = []
queued = []
async def send(client, request, **kwargs):
captured.append((request, json.loads(request.content) if request.content else None))
response = queued.pop(0) if queued else httpx.Response(200, json=completion())
response.request = request
return response
monkeypatch.setattr(httpx.AsyncClient, 'send', send)
return captured, queued
@pytest.mark.asyncio
@pytest.mark.parametrize('name', [*minimax.RECOMMENDED_MODELS, 'MiniMax-M2.7', 'private-deployment'])
@pytest.mark.parametrize(
'base_url', [None, 'https://api.minimax.chat/v1', 'https://api.minimaxi.com/v1', 'https://proxy.test/v1']
)
async def test_saved_endpoint_and_model_are_not_migrated(provider, wire, name, base_url):
if base_url:
provider.requester_cfg['base_url'] = base_url
await provider.invoke_llm(None, runtime(provider, name), [Message(role='user', content='hello')])
request, body = wire[0][-1]
assert str(request.url) == f'{base_url or "https://api.minimax.io/v1"}/chat/completions'
assert body['model'] == name
assert body['messages'] == [{'role': 'user', 'content': 'hello'}]
assert not {'thinking', 'reasoning_effort', 'reasoning_split', 'service_tier'}.intersection(body)
@pytest.mark.asyncio
@pytest.mark.parametrize('level', ['enabled', 'low', 'medium', 'high', 'xhigh', 'max'])
async def test_flash_effort_is_serialized_without_changing_input(provider, wire, level):
content = [
{'type': 'text', 'text': 'inspect'},
{'type': 'image_url', 'image_url': {'url': 'https://image.test/a.png'}},
]
await provider.invoke_llm(None, runtime(provider, level=level), [Message(role='user', content=content)])
body = wire[0][-1][1]
assert body['messages'][0]['content'] == content
if level == 'enabled':
assert body['thinking'] == {'type': 'adaptive'}
assert 'reasoning_effort' not in body
else:
assert body['reasoning_effort'] == level
assert 'thinking' not in body
@pytest.mark.asyncio
@pytest.mark.parametrize('level,expected', [('enabled', 'adaptive'), ('disabled', 'disabled')])
async def test_m3_thinking_switch(provider, wire, level, expected):
await provider.invoke_llm(None, runtime(provider, 'MiniMax-M3', level), [])
assert wire[0][-1][1]['thinking'] == {'type': expected}
@pytest.mark.asyncio
@pytest.mark.parametrize(
'name,level', [('MiniMax-M3.1-Flash', 'disabled'), ('MiniMax-M3', 'max'), ('MiniMax-M2.7', 'disabled')]
)
async def test_unsupported_thinking_rejected_before_http(provider, wire, name, level):
with pytest.raises(errors.RequesterError):
await provider.invoke_llm(None, runtime(provider, name, level), [])
assert wire[0] == []
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [200, 404])
async def test_direct_discovery_fallback_preserves_custom_and_nontext_models(provider, wire, status):
names = ['MiniMax-M2.7', 'MiniMax-01', 'abab6.5-chat', 'speech-02-hd', 'embo-01', 'private-deployment']
wire[1].append(httpx.Response(status, json={'data': [{'id': name} for name in names]}))
models = (await provider.scan_models('synthetic-minimax-test'))['models']
assert [model['id'] for model in models[:2]] == list(minimax.RECOMMENDED_MODELS)
assert all(model['context_length'] == 1_000_000 for model in models[:2])
assert all(set(model['abilities']) == {'func_call', 'vision', 'reasoning'} for model in models[:2])
assert {model['id'] for model in models[2:]} == (set(names[3:]) if status == 200 else set())
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [401, 403, 429, 500])
async def test_catalog_errors_are_not_hidden_by_recommendations(provider, wire, status):
wire[1].append(httpx.Response(status, json={'error': 'fixture'}))
with pytest.raises(errors.RequesterError, match=str(status)):
await provider.scan_models()
@pytest.mark.asyncio
async def test_gateway_keeps_all_advertised_current_variants_and_metadata(provider, wire):
provider.requester_cfg.update(requester_name='openrouter-chat-completions', base_url='https://openrouter.ai/api/v1')
names = [
'other/minimax-m2',
'minimax/minimax-m3',
'minimax/minimax-m3.1-flash',
'minimax/minimax-m3.1-flash:free',
'minimax/minimax-m4',
'minimax/minimax-m2.7',
'minimax/speech-02-hd',
]
wire[1].append(httpx.Response(200, json={'data': [{'id': name, 'context_length': 12345} for name in names]}))
models = (await provider.scan_models())['models']
assert [model['id'] for model in models[:3]] == [names[2], names[3], names[1]]
assert {model['id'] for model in models} == set(names) - {names[5]}
assert all(model.get('context_length') == 12345 for model in models if model['type'] == 'llm')
wire[1].append(httpx.Response(200, json={'data': []}))
assert await provider.scan_models() == {'models': []}
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('hide_thinking', [False, True])
@pytest.mark.parametrize('name,level', [('MiniMax-M3.1-Flash', 'provider_default'), ('MiniMax-M3', 'disabled')])
async def test_tool_followup_replays_original_assistant_not_rendered_text(
provider, wire, stream, hide_thinking, name, level
):
tool_call = {'id': 'lookup-1', 'type': 'function', 'function': {'name': 'lookup', 'arguments': '{}'}}
original = {
'role': 'assistant',
'content': '<think>private</think>answer',
'reasoning_content': 'plan',
'tool_calls': [tool_call],
}
model = runtime(provider, name, level)
if stream:
deltas = [
{'role': 'assistant', 'reasoning_content': 'pl'},
{'reasoning_content': 'an'},
{'content': '<thi'},
{'content': 'nk>private</think>'},
{'content': 'answer'},
{'tool_calls': [{'index': 0, **tool_call}]},
]
events = []
for index, delta in enumerate([*deltas, {}]):
event = completion()
event.update(object='chat.completion.chunk')
event['choices'] = [
{'index': 0, 'delta': delta, 'finish_reason': 'tool_calls' if index == len(deltas) else None}
]
events.append('data: ' + json.dumps(event) + '\n\n')
wire[1].append(
httpx.Response(
200, content=''.join(events) + 'data: [DONE]\n\n', headers={'content-type': 'text/event-stream'}
)
)
chunks = [chunk async for chunk in provider.invoke_llm_stream(None, model, [], remove_think=hide_thinking)]
fields = {}
calls = []
for chunk in chunks:
for key, value in (chunk.provider_specific_fields or {}).items():
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
if chunk.tool_calls:
calls = chunk.tool_calls
response = Message(
role='assistant',
content=''.join(chunk.content or '' for chunk in chunks),
provider_specific_fields=fields,
tool_calls=calls,
)
else:
wire[1].append(httpx.Response(200, json=completion(original)))
response, _ = await provider.invoke_llm(None, model, [], remove_think=hide_thinking)
if hide_thinking:
assert response.content == 'answer'
assert response.provider_specific_fields[minimax.RAW_CONTENT_FIELD] == original['content']
saved = Message.model_validate_json(response.model_dump_json())
await provider.invoke_llm(
None, model, [saved, Message(role='tool', tool_call_id='lookup-1', content='result')], funcs=[object()]
)
followup = wire[0][-1][1]
assert {key: followup['messages'][0][key] for key in original} == original
assert minimax.RAW_CONTENT_FIELD not in json.dumps(followup)
assert followup['messages'][1]['tool_call_id'] == 'lookup-1'
assert followup['tools'][0]['function']['name'] == 'lookup'
def test_private_replay_field_does_not_leak_when_switching_provider(provider):
message = Message(
role='assistant', content='visible', provider_specific_fields={minimax.RAW_CONTENT_FIELD: 'private'}
)
assert provider._convert_messages([message], reasoning_family='openai') == [
{'role': 'assistant', 'content': 'visible'}
]
assert message.provider_specific_fields == {minimax.RAW_CONTENT_FIELD: 'private'}
def test_direct_catalog_does_not_overwrite_advertised_context_or_drop_nontext():
advertised = [
{'id': 'MiniMax-M3', 'name': 'MiniMax-M3', 'type': 'llm', 'context_length': 54321},
{'id': 'minimax-m2-embedding', 'name': 'embedding', 'type': 'text-embedding'},
]
models = minimax.arrange_catalog(advertised, direct=True)
assert len(models) == 3
assert models[1]['context_length'] == 54321
assert models[2] == advertised[1]
assert 'abilities' not in advertised[0]
@pytest.mark.asyncio
@pytest.mark.parametrize('requester_name', ['openai-chat-completions', 'openrouter-chat-completions'])
async def test_other_providers_do_not_get_minimax_404_fallback(provider, wire, requester_name):
provider.requester_cfg['requester_name'] = requester_name
wire[1].append(httpx.Response(404, json={}))
with pytest.raises(errors.RequesterError, match='404'):
await provider.scan_models()
@pytest.mark.asyncio
@pytest.mark.parametrize('content', ['<think>hidden</think>', '<think>hidden</think>answer'])
async def test_stream_without_finish_reason_flushes_one_complete_history_snapshot(provider, wire, content):
events = []
for fragment in [content[:5], content[5:]]:
event = completion()
event['object'] = 'chat.completion.chunk'
event['choices'] = [{'index': 0, 'delta': {'content': fragment}, 'finish_reason': None}]
events.append('data: ' + json.dumps(event) + '\n\n')
wire[1].append(
httpx.Response(200, content=''.join(events) + 'data: [DONE]\n\n', headers={'content-type': 'text/event-stream'})
)
chunks = [chunk async for chunk in provider.invoke_llm_stream(None, runtime(provider), [], remove_think=True)]
snapshots = [
chunk.provider_specific_fields[minimax.RAW_CONTENT_FIELD]
for chunk in chunks
if minimax.RAW_CONTENT_FIELD in (chunk.provider_specific_fields or {})
]
assert snapshots == [content]