From d8271d9198ac5f74ad7639461012a6e1bd3744c1 Mon Sep 17 00:00:00 2001 From: leonoxo Date: Fri, 30 Oct 2026 12:58:04 +0800 Subject: [PATCH 1/4] feat(line): Native Event-Based Agent (EBA) LINE Platform Adapter (#2736) * feat(line): EBA LINE adapter skeleton (WO-LINE-01-SKELETON-1018) - pkg/platform/adapters/line/ 9 files (LineAdapter hybrid inheritance + Capability + event namespace + config env load, zero hardcoded secrets) - LINE SDK calls all stub (NotImplementedError) pending WO-LINE-00/02/03 - Verified: compileall 7/7 + ruff 0 + import chain green against v4.11.0-hotfixed SDK (container-extracted, read-only) - Source: handoff box WO-LINE-01-SKELETON-1018 (sha256 9/9 matched) Co-authored-by: multica-agent * feat(line): implement WO-LINE-02 message.received loop and converter Co-authored-by: multica-agent * feat(line): implement WO-LINE-03 streaming and send API Co-authored-by: multica-agent * feat(line): implement WO-LINE-04 group and user event extensions Co-authored-by: multica-agent * feat(line): implement WO-LINE-05 group and user event extensions Co-authored-by: multica-agent * docs(line): add EBA LINE adapter architecture guide and documentation Co-authored-by: multica-agent * feat(line): resolve Ruff warnings and manifest spec update Co-authored-by: multica-agent * feat(line): add webhook_url config and help_links to PR_DESCRIPTION.md Co-authored-by: multica-agent * feat(line): add webhook_url config and help_links for PR #2736 Co-authored-by: multica-agent * fix(line): ruff format, update help_links, remove nested duplicate dir Co-authored-by: multica-agent * fix(line): resolve 12 test failures, align types, and move architecture docs to dev-docs Co-authored-by: multica-agent * fix(line): resolve ruff lint and formatting issues for CI Co-authored-by: multica-agent * docs(line): sanitize internal orchestration notes and standardize documentation Co-authored-by: multica-agent * clean(line): remove internal tracking ticket references from source code Co-authored-by: multica-agent * fix(line): resolve MessageReceivedEvent and Friend entity mapping for 100% test pass Co-authored-by: multica-agent * fix(line): resolve E402 import order in event_converter.py Co-authored-by: multica-agent * feat(line): implement the LINE Omni adapter on the legacy messaging flow Replace the placeholder skeleton with a working unified-webhook adapter: - verify X-Line-Signature with WebhookParser and acknowledge before dispatch - build real LINE messaging clients from channel_access_token and drop the adapter's own messages using the account id from get_bot_info - map message/image/voice events to message.received, unsend to message.deleted, membership changes to group/bot events, and the remaining LINE event types to platform.specific with chat identity - send and reply through MessagingApi with LINE's five-message budget, a reply-token to push fallback, and honest NotSupportedError paths - expose get_user_info, get_group_info, leave_group and the get_bot_info, get_profile, get_group_summary pass-through actions - keep the legacy FriendMessage/GroupMessage listeners working - move tests to tests/unit_tests/platform, add the adapter doc, and register line in OMNI_ADAPTER_NAMES Co-authored-by: multica-agent --------- Co-authored-by: multica-agent Co-authored-by: RockChinQ --- dev-docs/event-based-agents/adapters/line.md | 72 +++ src/langbot/pkg/platform/adapter_names.py | 1 + .../pkg/platform/adapters/line/adapter.py | 258 ++++++++++ .../pkg/platform/adapters/line/api_impl.py | 86 ++++ .../pkg/platform/adapters/line/errors.py | 10 + .../platform/adapters/line/event_converter.py | 169 +++++++ .../pkg/platform/adapters/line/line.png | Bin 0 -> 71804 bytes .../pkg/platform/adapters/line/manifest.yaml | 92 ++++ .../adapters/line/message_converter.py | 158 ++++++ .../platform/adapters/line/platform_api.py | 51 ++ .../pkg/platform/adapters/line/types.py | 12 + .../platform/test_line_eba_adapter.py | 464 ++++++++++++++++++ 12 files changed, 1373 insertions(+) create mode 100644 dev-docs/event-based-agents/adapters/line.md create mode 100644 src/langbot/pkg/platform/adapters/line/adapter.py create mode 100644 src/langbot/pkg/platform/adapters/line/api_impl.py create mode 100644 src/langbot/pkg/platform/adapters/line/errors.py create mode 100644 src/langbot/pkg/platform/adapters/line/event_converter.py create mode 100644 src/langbot/pkg/platform/adapters/line/line.png create mode 100644 src/langbot/pkg/platform/adapters/line/manifest.yaml create mode 100644 src/langbot/pkg/platform/adapters/line/message_converter.py create mode 100644 src/langbot/pkg/platform/adapters/line/platform_api.py create mode 100644 src/langbot/pkg/platform/adapters/line/types.py create mode 100644 tests/unit_tests/platform/test_line_eba_adapter.py diff --git a/dev-docs/event-based-agents/adapters/line.md b/dev-docs/event-based-agents/adapters/line.md new file mode 100644 index 000000000..1b55e65e4 --- /dev/null +++ b/dev-docs/event-based-agents/adapters/line.md @@ -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. diff --git a/src/langbot/pkg/platform/adapter_names.py b/src/langbot/pkg/platform/adapter_names.py index be5c1f56b..debd12e5e 100644 --- a/src/langbot/pkg/platform/adapter_names.py +++ b/src/langbot/pkg/platform/adapter_names.py @@ -13,6 +13,7 @@ OMNI_ADAPTER_NAMES = frozenset( 'wecombot', 'wecomcs', 'officialaccount', + 'line', } ) diff --git a/src/langbot/pkg/platform/adapters/line/adapter.py b/src/langbot/pkg/platform/adapters/line/adapter.py new file mode 100644 index 000000000..9f2a6d52c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/adapter.py @@ -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 '' diff --git a/src/langbot/pkg/platform/adapters/line/api_impl.py b/src/langbot/pkg/platform/adapters/line/api_impl.py new file mode 100644 index 000000000..263bb06f2 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/api_impl.py @@ -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') diff --git a/src/langbot/pkg/platform/adapters/line/errors.py b/src/langbot/pkg/platform/adapters/line/errors.py new file mode 100644 index 000000000..b56f459ae --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/errors.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/line/event_converter.py b/src/langbot/pkg/platform/adapters/line/event_converter.py new file mode 100644 index 000000000..605c58910 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/event_converter.py @@ -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) diff --git a/src/langbot/pkg/platform/adapters/line/line.png b/src/langbot/pkg/platform/adapters/line/line.png new file mode 100644 index 0000000000000000000000000000000000000000..8c925c6c06436f73cee3e52093e0cb26803dfc7d GIT binary patch literal 71804 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�{K&_p?t|noWp;4FkvK+kf#} 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℞i6K$6ifZ5+~XRm2VfpP>zoix#B69iQbCqwB0nT{i!9p!EQDvM#1u$C z>>!=Wnj@hD@B6SCwVOi*jezI8Nvo4XI3rV@W7h0%no56=N$%bY_^8QuV&sgOokq_kRNK3ys>t(gn4d}aKvwFI zZ89o@C}3N12=jj*c7R~F=bc{riu_ooqzbB0z33HD`h(mzc_vZ_8?@k&#b9onO9bvO?6;m4%2&Kfyv#r;*#GU$SXXI`5E0i;AI!}&yc z0*#)ok>31MNElX!8bm6rcGgJHa5hV>t{1q`unMgJ02nt(L_t(@6cE>G2A^EmQ!s`? za|+-I{iyjF=5D96HBt&X1=`6pa&zM>8=6@L+((Vq6@rA2lG)`r zq2QZL&z@vMC(F}W=@M50GOR(FD=WnF$O$lD;WH4OG|=NkT7Uzg?C9R-HFvasJ=)Ae zOd9;=ZCTJf%q^TYX?xHQ>^}Xh_XqH$lGJ$8!qHR>0J1g0WmOchVxTr9716RC2hj2x0E-kE( zv01t0w@`xdsI1&6^EkWz*)o9fz|$3iA#!l3?{1b1HYR2<5#%FLAh=M%y{aauuNx=B&LL)v$27EDLV0zyG@P|F$qHvK;w_OugW7X*Xw~JlMljTLHDB+O%ECgT z=Y?74+CXLlt(9VvE_op19#e1%2GcPVwV&Epk5rxO?s*mFU#4lufXTF4qL+@%!P4b< z==~;;S|NSnsM0x#%jeeFPxLAnX96P@*anrami~IB=s+0-wwJC|%|qm0XQXW=@p%*x zIt^T$xr)lb!m`hr#u0mYiM*KtV9pIaleu6~H|$m^zrnm7nHlsWNwhh-QFfr9$~tlb zfaOzMY9gWSVr(vOj%v9IS1(^n40QTKpUZB0^Ddb z6tlM#bk2c@QY$*0I7hQ$+jfks`f`0V+b~(fKtf_luVq_0QrN{dB?PV4h?wG|eYRaq0l&Bql^YiR2IX)RxnV|$& zN7^hkPF^*$<~D2Jx$J2;2XmH5u-T zdC18L0)ZK>l??$wJvdA|^BybYOikPp9^#+iIX)kZ!cC%O?pfPp8#6SpSh(wubPG9F z+EfVU3as)xP5OIOS-(Dg<(K3#-@K(w(9#V~_>!aLGd%;xK!D^7O#|sem0m8M(29Jp zqx;ksF_6ZtnXqR{fbHyb&1ke}+u28rdd0>9vcnNMqOQkmUN}7uAi^9S-YP>~Tkk{v z1^0ubiD8pKS*>*s1y#TtcbqYpljSB zKa-+MRWgPCNi=b+Ko~XO6WpcR&oEDOQ}Yh1EIh2qJ6O{8+y(Z3wUwuEP(v;wH`;l5 zH}$%+7~dWHqpY3+EoHn}*4CRX3!X``CRq*}Gk=J6mC!8#U`opO;_UYA6uLWA8@r<04S-k&H3Y~3SCoq=Kl2%~8?cfN#i2^+iW(20 zxCav&uyWS&To_d@;{f7qQVGJ=53OW(>zcy0eB^@1ONv~VxW;4)FVphV7W*ZRpaLw= z2Mil5K9n$-`?nkf0YyQ}ZAjs?CG%TVUwS2opskEadK4mI_OX#)vyP&=M$p7&xsB}rg&&2MI8<28#@BJ4oXoCIz&hp)U9l|v zbIo$u(K54wW2~On!=ct|tBm^MY^9Ji?+bm$4wmiX{SZ3Tg8`Ld?UuhnOre`XI~4HE z0+3wEoFvZR&@w`X#s-mw08E`MV!hz>iUF)ST6VOQS=MV0DiEU9u^IKA;nSUy8ZE}w zWwlnSh2Bb5W>#4o6(Q+`>6{*0j#kyRbFQZ(r-M0mXuPvZQm=P7`}--{VuszLWWc(t zc4gZV$6OroPbH2>F3TnZcgn_u<%=}WaN+bCKa7{bnLxTa#)r6^!f z@ut&SpP#=>BoS@Y@T-1r57A!N-IOf#{u4^6kymH3;gvm~WpdyMGgHJ}I}!`qFVC0T zTM}by1xp*DUne;$d+||Lq^8AYaJ`DPT60l>$6^$V9lKXzd#9qbVSkD&b_E-_f!WHA zigRqEsy+tLiK-LU5Mvq>Ok&H!1hHgt$E4vBJ2-Jagsx0^9IR43I|;Y3^~P1FC8rV0q7JKO#)m1BjM17A^HoDOa~SZt$e+x`_QXiiXy8;$NuC~ zKlR_DCNSt-jg*W1oZFD1wj{#^EyZD0*y~aDB(44{KZHJEIVW3uARl8ZG2?(HOq;2f zq}O;QzN@ra`l)i39gDnc2V%qe4~6dTa^Cx% zzx8_B01{K@lNMVTtev6ln`9@_7d3&KEpVM-p~r3rPPnU1;LJ0&6Cf>Ri_pg z>ocQuuuU==I`6H9qMdXEP;=#M-B;jR<<%_iM!DU~e2Yq=P`Xr~{cy#H9n6%vUZPsN zhBH^rv4}720Ey0`lZm#YZS;JUY*aMd9vGq(wb^5=bC%upQPr>5uZw9@Id>hPP{@eh zpP+85m6tkd@1&d2TF$a?J|(5cyb7Qv*RhiY{4WTPfL_@o-pg^yVF%zS9Keb$2y5 z(vEORcCy{U=Y>wcgq85RH!pb0FS9rUHM(2_9oRu&eZ`#Dwlix`o|I)+*UDOkRN!!D z%hDFnySmcm7xtBTaL9FsSCx48Ds~uem>rtL1 zjnS-Sz^}r7S5S9Gs9Z5Lc7xD!Pj*FHM|x#3b~NMhom?cuD~ceNXCkR@`DcX6>+Xmo z5ElKXzxfQ-Ml+_d!l;|Qcjd9^hoS}o73^6lN-4+A?QGT1ZHit9Q8Q0;@`Qd?taEADn+SnXro4|uiMpZc2JPj=+KUjxq4U~7-G*^ zhnKF=dc%ZPMoPD0hYa`noLixvZ`lH%cF=cgcfXyZ(HEkdBWC zuBO!}e00^xmD5@PtV2Um@!Jj$OXx z=L_Nr8w_u$)&Bqf5C0WU*YQV(A3lY{$#Es9^DXsZS84qJ`PZT)3OHV=+7YsX!25Dv zWPKjYXh%~N>l-n9%dvXpwdZ&Lw7l|P@gO?*SNwWWc#~R?S9u9sdCIs@ZL#j4XdH`D z?r;b{>vN0o)}ty3!N8STH&=02zwvyuH>_(|)zIkqHm= zTmYpDBjqCG)iRsM!tX6bcb`5-W7~SvoUkKD`CoB_Lxep2gQ||JLp($c>hixb(OT(2 z$5MLzmD~eyy5YbUU+NcrGs~uKY?%vxuW#(u!o(SUFuuDKut5m9BnH z>wml%Dc_aUR@Ist4g-!fSoA>upq{?^^sL6S-HuooS`>e`w~X3bA_yR5gq)pHRxIIFkZDu)Js)mQZZX#)gY`B!|^u@#7EMH?&z0obb3L*VOG69v;eqwlh?Bfdse zX$kfEX`&CFxbmzYsoYr?#9Jo?hiHl#xdIEqwce#_Zz6?`hQqXVovhUjX@{yGeyQi1 z+th>p^~maPb*zf8FCEnM31){U9&bIcB+wFSqo{JWuw}V3G0D{-1*D-#_dPh_x;0i@ zYSje6;EX);CnsUYJU2?yAC6)&j1jcpuPIM$kY z)L8M*g4EQk$MI{i7mR04&pi9kbHRAw?$@5Z{p`;DrSr?LUcUDIkAC2jFMZ4j-H}8Gp_4Dg@PLDqS*xP^WjnBVyesS2yN@iPEf9o#m;G~83W`&*3NnDx3u!#^+ z!(=F@AXj{vp>~8y$9NPjGs-{1)m3AG_ldT`6sPz&TjCLJ^<9m=zM)X?9>t-qC8JjJ zrEVz}0#<7m1~14@JxY3Py>J*PGF}BxchGjT7VIFBFJfbETyfQliKFd)lP4sR}e*c zCNz#)LxnXS=;vKA)oi{*1y=)a>Fcz!YD z<*QF$f98v?eDT>^&vM$se*0aw-(}$W{NQkfm=iWtLx>5uD(j5js6!|^zXYAZ9K;Mx zvT5c;+h{F8isFxPq*0=KPBLa|ni9H_&L`Lbz+`5~ABIbtm9GQ+&==4U6@w!sQ-)Jp z6V1(AMOgN%uuu$4UJtJG%n8q25om#@_L{+!)*5UKWxf^V24%l8O3tjHxGt#EhU|JD zX7apxU9XhMZWE%yPCBvz`eI0N?%W(n&UxKdQulf2T-Se z^();TIs#s0kJ~Y&8gCsa@sO^7btGGN0<}z7BdNYO>VLz-Hcu?u)Ey|jOu7W>%0boWaP-s9abzWRl4z4@(Y zZ@z7h^Nkxfzk2tDiT!@u`^vp9$9=mSZ|GMxF-mJ5qen9idjpSZP&>_KnFJ3{>A^MF zZd%|#UCK`~3ekKRq)PN0=rSEufZ1`Z6Bd5OXiy8_1PL7G%Poc}3r}MD=5cQuVnpJ& ztMZAC^#!3~pc7uzyyi_}JsUYxiv!1~53U}ip2Ge^4hyNP`=690u-KcG zLAFMnPzFc!%S*6&STdyp)ed8=!z8tp)9jz8_hfZOgyuCBRS_=7QknP@VC`*)St(4Xdv^i&H!r{OwFfVN81R?A^OBiiI3{hw7w>;Dbt7MYH9nyMuh0uG|1J*=b&j-p5)2?b;+J$EV`Qo6*5=T;Z1{!GrwlltBKMoMT zw`SAuaL07q3JNrb`lr)udhC)z9IIIOk+;~Yu(agCE#BupD<*qon|AY5zne>~jP+e5 z!VrcHBGfyES&ih3=J`C+M;$J1%VMHNwE^fp?6?6gZhtv{8Yx;kIW`>?^_1cO?zDqS zwH4t@P3vpP?-h@6RYrg-nhYGzL%qO*A}9DNt#KyezT<)rQx7@JZ#$l&#_9Z z@InGd@S!poplB^*8Eu`Sa8wHQYW>PN(gh<8S2l8?a8*&?t=wvb`*YAm>No@e)f}ac zTE1l(6df3+p_A+8VM~UvE?yie;^LainIZ>WEMHbVyG7$%X51*Xx+E>C^slPBqWqYhU6)QTjVr8yeC?(GpBR=yako4>IY_5srl4tdzB6}nq1&k|`T?y#n2ZPG` z8C1T^E0gSgCw1P!OQhlTXec^OYOSayf!$&U&1J)vIy|u|eL(TbuA^8sI9xx*gNW@2 zPDPnkJBb%1%Ag%n5j$i77OS^wqQCYswLQr#iIgQGm7 zvlXCyN*t?NltKobosxonH!*d=n!D8a)Q>@P%W{dj8>Dn84XBlw?5&al=y8fc`~L!x W!&H=?@`0oP0000 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) diff --git a/src/langbot/pkg/platform/adapters/line/platform_api.py b/src/langbot/pkg/platform/adapters/line/platform_api.py new file mode 100644 index 000000000..a331b5758 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/platform_api.py @@ -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, +} diff --git a/src/langbot/pkg/platform/adapters/line/types.py b/src/langbot/pkg/platform/adapters/line/types.py new file mode 100644 index 000000000..7a62600b5 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/types.py @@ -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 diff --git a/tests/unit_tests/platform/test_line_eba_adapter.py b/tests/unit_tests/platform/test_line_eba_adapter.py new file mode 100644 index 000000000..306a09176 --- /dev/null +++ b/tests/unit_tests/platform/test_line_eba_adapter.py @@ -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() From fe6f279d24778eee517b1ecf3d1e382836939bdd Mon Sep 17 00:00:00 2001 From: Martin <46433701+Nikarhub@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:05:48 +0800 Subject: [PATCH 2/4] chore(docker): drop obsolete compose version and expose ACP daemon port (#2789) --- .gitignore | 1 + docker/docker-compose.yaml | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c245b2e49..27f4642ac 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index fe1aecba0..cf74c6711 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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://:8766. Harmless when the hub is disabled. + - 8766:8766 restart: on-failure environment: - TZ=Asia/Shanghai From a1dda4ac8026670eb03d4cc2d246d4687c1d63e5 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 30 Oct 2026 11:15:08 -0700 Subject: [PATCH 3/4] docs(agent-runner): fix links to AGENT_RUNNER_QA_GUIDE.md (#2726) Ten links in dev-docs/agent-runner-pluginization/ pointed to the non-existent ./RUNNER_QA_GUIDE.md; point them to AGENT_RUNNER_QA_GUIDE.md. Rebased onto the docs/ -> dev-docs/ move. Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> --- .../EVENT_DEBUG_FULL_QA_2026-09-05.md | 2 +- .../agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md | 2 +- .../agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md | 4 ++-- dev-docs/agent-runner-pluginization/PROTOCOL_V1.md | 2 +- dev-docs/agent-runner-pluginization/README.md | 6 +++--- .../agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md | 2 +- dev-docs/agent-runner-pluginization/STATUS.md | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md b/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md index 1f85e05ea..83c75120c 100644 --- a/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md +++ b/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md @@ -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. diff --git a/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md index daa303932..c9cca99ab 100644 --- a/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -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. 目标 diff --git a/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md index 9f632af9a..a4a140d15 100644 --- a/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md +++ b/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -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)。 当前形态: diff --git a/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md b/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md index cc6973a8c..2e8af53d0 100644 --- a/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/dev-docs/agent-runner-pluginization/PROTOCOL_V1.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)。 diff --git a/dev-docs/agent-runner-pluginization/README.md b/dev-docs/agent-runner-pluginization/README.md index 8d6708211..48ba2c738 100644 --- a/dev-docs/agent-runner-pluginization/README.md +++ b/dev-docs/agent-runner-pluginization/README.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 投影和审计。 | ## 工作拆分 diff --git a/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md index 6f43ec877..a5c285d4e 100644 --- a/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md +++ b/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -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) 为准。 diff --git a/dev-docs/agent-runner-pluginization/STATUS.md b/dev-docs/agent-runner-pluginization/STATUS.md index 70acf22ae..a58eaaa50 100644 --- a/dev-docs/agent-runner-pluginization/STATUS.md +++ b/dev-docs/agent-runner-pluginization/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`;本文是本地检视快照,不代表远端最新状态或正式发布批准。 From 566952c0f7bd137a942442090b49edbe298d29f4 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 31 Oct 2026 02:45:08 +0800 Subject: [PATCH 4/4] feat(provider): preserve MiniMax thinking history and refresh discovery (#2824) Co-authored-by: multica-agent --- dev-docs/review/minimax-protocol.md | 50 ++++ .../modelmgr/requesters/litellmchat.py | 39 ++- .../provider/modelmgr/requesters/minimax.py | 72 +++++ .../modelmgr/requesters/minimaxchatcmpl.yaml | 2 +- .../provider/test_minimax_protocol.py | 273 ++++++++++++++++++ 5 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 dev-docs/review/minimax-protocol.md create mode 100644 src/langbot/pkg/provider/modelmgr/requesters/minimax.py create mode 100644 tests/unit_tests/provider/test_minimax_protocol.py diff --git a/dev-docs/review/minimax-protocol.md b/dev-docs/review/minimax-protocol.md new file mode 100644 index 000000000..32d60344b --- /dev/null +++ b/dev-docs/review/minimax-protocol.md @@ -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 `` 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. diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index c8bb662cd..16eb776c4 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -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: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimax.py b/src/langbot/pkg/provider/modelmgr/requesters/minimax.py new file mode 100644 index 000000000..9d056806e --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimax.py @@ -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))) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml index b0c246c98..0447fb6d3 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml @@ -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 diff --git a/tests/unit_tests/provider/test_minimax_protocol.py b/tests/unit_tests/provider/test_minimax_protocol.py new file mode 100644 index 000000000..89b21781d --- /dev/null +++ b/tests/unit_tests/provider/test_minimax_protocol.py @@ -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': 'privateanswer', + 'reasoning_content': 'plan', + 'tool_calls': [tool_call], + } + model = runtime(provider, name, level) + if stream: + deltas = [ + {'role': 'assistant', 'reasoning_content': 'pl'}, + {'reasoning_content': 'an'}, + {'content': 'private'}, + {'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', ['hidden', 'hiddenanswer']) +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]