mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 21:06:18 +08:00
Merge remote-tracking branch 'origin/master' into feat/blocking-interactions
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user