mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
Merge remote-tracking branch 'origin/master' into feat/blocking-interactions
This commit is contained in:
@@ -13,6 +13,7 @@ OMNI_ADAPTER_NAMES = frozenset(
|
||||
'wecombot',
|
||||
'wecomcs',
|
||||
'officialaccount',
|
||||
'line',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
import quart
|
||||
from linebot.v3.exceptions import InvalidSignatureError
|
||||
from linebot.v3.messaging import (
|
||||
ApiClient,
|
||||
Configuration,
|
||||
MessagingApi,
|
||||
MessagingApiBlob,
|
||||
PushMessageRequest,
|
||||
ReplyMessageRequest,
|
||||
)
|
||||
from linebot.v3.webhook import WebhookParser
|
||||
from linebot.v3.webhooks import Event
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
from langbot.pkg.platform.adapters.line.api_impl import LineAPIMixin
|
||||
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
|
||||
from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter
|
||||
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
|
||||
from langbot.pkg.platform.adapters.line.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME, MAX_MESSAGES_PER_REQUEST
|
||||
|
||||
|
||||
class LineAdapter(LineAPIMixin, abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""LINE Messaging API adapter in unified webhook mode."""
|
||||
|
||||
api_client: typing.Any = pydantic.Field(exclude=True)
|
||||
bot: typing.Any = pydantic.Field(exclude=True)
|
||||
blob_api: typing.Any = pydantic.Field(exclude=True)
|
||||
parser: typing.Any = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: LineMessageConverter = LineMessageConverter()
|
||||
event_converter: LineEventConverter = LineEventConverter()
|
||||
|
||||
config: dict
|
||||
config_secret: str = ''
|
||||
bot_uuid: str | None = None
|
||||
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
background_tasks: set = pydantic.Field(default_factory=set, exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
missing_keys = [key for key in ('channel_access_token', 'channel_secret') if not config.get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f'LINE adapter missing config: {missing_keys}')
|
||||
configuration = Configuration(access_token=config['channel_access_token'])
|
||||
api_client = ApiClient(configuration)
|
||||
bot = MessagingApi(api_client)
|
||||
blob_api = MessagingApiBlob(api_client)
|
||||
bot_account_id = config.get('bot_account_id', '') or ''
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id=bot_account_id,
|
||||
config_secret=config['channel_secret'],
|
||||
api_client=api_client,
|
||||
bot=bot,
|
||||
blob_api=blob_api,
|
||||
parser=WebhookParser(config['channel_secret']),
|
||||
listeners={},
|
||||
background_tasks=set(),
|
||||
message_converter=LineMessageConverter(),
|
||||
event_converter=LineEventConverter(bot_account_id=bot_account_id, blob_api=blob_api),
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'message.deleted',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'bot.invited_to_group',
|
||||
'bot.removed_from_group',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_user_info',
|
||||
'get_group_info',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_events.MessageResult:
|
||||
messages = await self.message_converter.yiri2target(message)
|
||||
for batch in _batches(messages):
|
||||
request = PushMessageRequest(to=str(target_id), messages=batch)
|
||||
await asyncio.to_thread(self.bot.push_message, request)
|
||||
return platform_events.MessageResult(raw={'sent': len(messages), 'target_id': str(target_id)})
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> platform_events.MessageResult:
|
||||
source = await LineEventConverter.yiri2target(message_source)
|
||||
messages = await self.message_converter.yiri2target(message)
|
||||
reply_token = str(getattr(source, 'reply_token', '') or '')
|
||||
chat_id = _source_chat_id(source) or str(getattr(message_source, 'chat_id', '') or '')
|
||||
for batch in _batches(messages):
|
||||
if reply_token:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self.bot.reply_message, ReplyMessageRequest(reply_token=reply_token, messages=batch)
|
||||
)
|
||||
# A reply token is single-use; later batches of the same turn fall back to push.
|
||||
reply_token = ''
|
||||
continue
|
||||
except Exception:
|
||||
await self.logger.warning(
|
||||
f'LINE reply token rejected, falling back to push: {traceback.format_exc()}'
|
||||
)
|
||||
reply_token = ''
|
||||
if not chat_id:
|
||||
raise NotSupportedError('reply_message:line_missing_target')
|
||||
await asyncio.to_thread(self.bot.push_message, PushMessageRequest(to=chat_id, messages=batch))
|
||||
return platform_events.MessageResult(raw={'sent': len(messages)})
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self, dict(params or {}))
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
registered = self.listeners.get(event_type)
|
||||
if registered is callback:
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request) -> typing.Any:
|
||||
signature = request.headers.get('X-Line-Signature')
|
||||
body = await request.get_data(as_text=True)
|
||||
if not signature:
|
||||
await self.logger.warning('LINE webhook rejected: missing X-Line-Signature header')
|
||||
return quart.Response('Missing X-Line-Signature header', status=400)
|
||||
try:
|
||||
events = self.parser.parse(body, signature)
|
||||
except InvalidSignatureError:
|
||||
await self.logger.warning(
|
||||
'LINE webhook rejected: invalid signature, check channel_access_token/channel_secret'
|
||||
)
|
||||
return quart.Response('Invalid signature', status=400)
|
||||
for event in events or []:
|
||||
task = asyncio.create_task(self._dispatch_native_event(event))
|
||||
self.background_tasks.add(task)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
# Acknowledge immediately; LINE retries any non-200 answer.
|
||||
return quart.Response('OK', status=200)
|
||||
|
||||
async def _dispatch_native_event(self, event: Event) -> None:
|
||||
try:
|
||||
if self.bot_account_id and _sender_id(event) == self.bot_account_id:
|
||||
return
|
||||
eba_event = await self.event_converter.target2yiri(event)
|
||||
if eba_event is None:
|
||||
return
|
||||
if isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||
legacy_event = eba_event.to_legacy_event()
|
||||
listener = self.listeners.get(type(legacy_event))
|
||||
if listener:
|
||||
await listener(legacy_event, self)
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'LINE native event error: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent) -> None:
|
||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
await callback(event, self)
|
||||
return
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
return False
|
||||
|
||||
async def run_async(self):
|
||||
if not self.bot_account_id:
|
||||
try:
|
||||
info = await asyncio.to_thread(self.bot.get_bot_info)
|
||||
self.bot_account_id = info.user_id
|
||||
self.event_converter.bot_account_id = info.user_id
|
||||
except Exception:
|
||||
await self.logger.warning(
|
||||
'LINE adapter could not resolve its account id; self-message filtering stays disabled'
|
||||
)
|
||||
await self.logger.info(f'{ADAPTER_NAME} adapter running in unified webhook mode')
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
for task in list(self.background_tasks):
|
||||
task.cancel()
|
||||
self.background_tasks.clear()
|
||||
await asyncio.to_thread(self.api_client.close)
|
||||
return True
|
||||
|
||||
|
||||
def _batches(messages: list[typing.Any]) -> list[list[typing.Any]]:
|
||||
"""Split a message array into LINE's per-request budget."""
|
||||
return [
|
||||
messages[index : index + MAX_MESSAGES_PER_REQUEST]
|
||||
for index in range(0, len(messages), MAX_MESSAGES_PER_REQUEST)
|
||||
]
|
||||
|
||||
|
||||
def _sender_id(event: Event) -> str:
|
||||
source = getattr(event, 'source', None)
|
||||
return str(getattr(source, 'user_id', '') or '')
|
||||
|
||||
|
||||
def _source_chat_id(event: Event) -> str:
|
||||
"""Chat id of the conversation a LINE event belongs to."""
|
||||
source = getattr(event, 'source', None)
|
||||
for attribute in ('group_id', 'room_id', 'user_id'):
|
||||
value = getattr(source, attribute, '') or ''
|
||||
if value:
|
||||
return str(value)
|
||||
return ''
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class LineAPIMixin:
|
||||
"""Optional universal APIs; LINE only exposes profile and group summaries."""
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
import asyncio
|
||||
|
||||
profile = await asyncio.to_thread(self.bot.get_profile, str(user_id))
|
||||
return platform_entities.User(
|
||||
id=profile.user_id,
|
||||
nickname=profile.display_name,
|
||||
avatar_url=profile.picture_url or '',
|
||||
)
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
summary = await asyncio.to_thread(self.bot.get_group_summary, str(group_id))
|
||||
except Exception as exc:
|
||||
raise NotSupportedError('get_group_info:group_not_found') from exc
|
||||
return platform_entities.UserGroup(
|
||||
id=summary.group_id,
|
||||
name=summary.group_name,
|
||||
avatar_url=summary.picture_url or '',
|
||||
)
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
import asyncio
|
||||
|
||||
await asyncio.to_thread(self.bot.leave_group, str(group_id))
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
# LINE does not expose a group member listing API.
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
raise NotSupportedError('get_group_list')
|
||||
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
raise NotSupportedError('get_friend_list')
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: platform_message.MessageChain,
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
raise NotSupportedError('delete_message')
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('forward_message')
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
except ModuleNotFoundError:
|
||||
|
||||
class NotSupportedError(Exception):
|
||||
def __init__(self, api_name: str, *args):
|
||||
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
|
||||
self.api_name = api_name
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from linebot.v3.webhooks import (
|
||||
BeaconEvent,
|
||||
Event,
|
||||
FollowEvent,
|
||||
GroupSource,
|
||||
JoinEvent,
|
||||
LeaveEvent,
|
||||
MemberJoinedEvent,
|
||||
MemberLeftEvent,
|
||||
MessageEvent,
|
||||
PostbackEvent,
|
||||
RoomSource,
|
||||
UnfollowEvent,
|
||||
UnsendEvent,
|
||||
UserSource,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
|
||||
from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME
|
||||
|
||||
|
||||
def _timestamp(event: Event) -> float:
|
||||
try:
|
||||
return float(event.timestamp) / 1000.0
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _chat_identity(event: Event) -> tuple[platform_entities.ChatType, str, typing.Any, str]:
|
||||
"""Return chat type, chat id, group entity and sender id for a LINE event source."""
|
||||
source = event.source
|
||||
if isinstance(source, GroupSource):
|
||||
return (
|
||||
platform_entities.ChatType.GROUP,
|
||||
source.group_id,
|
||||
platform_entities.UserGroup(id=source.group_id, name=source.group_id),
|
||||
source.user_id or source.group_id,
|
||||
)
|
||||
if isinstance(source, RoomSource):
|
||||
# LINE rooms are multi-user chats; treat them as group chats keyed by room id.
|
||||
return (
|
||||
platform_entities.ChatType.GROUP,
|
||||
source.room_id,
|
||||
platform_entities.UserGroup(id=source.room_id, name=source.room_id),
|
||||
source.user_id or source.room_id,
|
||||
)
|
||||
user_id = source.user_id if isinstance(source, UserSource) else ''
|
||||
return platform_entities.ChatType.PRIVATE, user_id, None, user_id
|
||||
|
||||
|
||||
class LineEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert LINE webhook events into unified EBA events and legacy message events."""
|
||||
|
||||
def __init__(self, bot_account_id: str = '', blob_api: typing.Any = None):
|
||||
self.bot_account_id = bot_account_id
|
||||
self.blob_api = blob_api
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
async def target2yiri(self, event: Event) -> typing.Optional[platform_events.EBAEvent]:
|
||||
"""Convert one LINE webhook event into a unified event; None when unsupported."""
|
||||
common = dict(
|
||||
adapter_name=ADAPTER_NAME,
|
||||
timestamp=_timestamp(event),
|
||||
source_platform_object=event,
|
||||
)
|
||||
chat_type, chat_id, group, sender_id = _chat_identity(event)
|
||||
chat = dict(
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
)
|
||||
if isinstance(event, MessageEvent):
|
||||
return platform_events.MessageReceivedEvent(
|
||||
**common,
|
||||
**chat,
|
||||
message_id=str(event.message.id),
|
||||
message_chain=await LineMessageConverter.target2yiri(
|
||||
event, blob_api=self.blob_api, bot_account_id=self.bot_account_id
|
||||
),
|
||||
sender=platform_entities.User(id=sender_id, nickname=sender_id),
|
||||
)
|
||||
if isinstance(event, UnsendEvent):
|
||||
return platform_events.MessageDeletedEvent(
|
||||
**common,
|
||||
**chat,
|
||||
message_id=str(event.unsend.message_id),
|
||||
operator=platform_entities.User(id=sender_id) if sender_id else None,
|
||||
)
|
||||
if isinstance(event, FollowEvent):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
**common, action='line.follow', data={**_event_payload(event), 'chat_id': chat_id}
|
||||
)
|
||||
if isinstance(event, UnfollowEvent):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
**common, action='line.unfollow', data={**_event_payload(event), 'chat_id': chat_id}
|
||||
)
|
||||
if isinstance(event, JoinEvent):
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
**common,
|
||||
group=platform_entities.UserGroup(id=chat_id, name=chat_id),
|
||||
)
|
||||
if isinstance(event, LeaveEvent):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
**common,
|
||||
group=platform_entities.UserGroup(id=chat_id, name=chat_id),
|
||||
)
|
||||
if isinstance(event, MemberJoinedEvent):
|
||||
member = _first_member(event.joined.members, chat_id)
|
||||
return platform_events.MemberJoinedEvent(**common, group=group, member=member)
|
||||
if isinstance(event, MemberLeftEvent):
|
||||
member = _first_member(event.left.members, chat_id)
|
||||
return platform_events.MemberLeftEvent(**common, group=group, member=member)
|
||||
if isinstance(event, PostbackEvent):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
**common,
|
||||
action='line.postback',
|
||||
data={
|
||||
'data': event.postback.data,
|
||||
'chat_type': chat_type.value,
|
||||
'chat_id': chat_id,
|
||||
'sender_id': sender_id,
|
||||
**({'params': event.postback.params} if event.postback.params else {}),
|
||||
},
|
||||
)
|
||||
if isinstance(event, BeaconEvent):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
**common,
|
||||
action='line.beacon',
|
||||
data={'hwid': event.beacon.hwid, 'type': event.beacon.type, 'chat_id': chat_id},
|
||||
)
|
||||
# Remaining LINE event types stay visible to event processors without a lossy mapping.
|
||||
action = getattr(event, 'type', '') or type(event).__name__
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
**common, action=f'line.{action}', data={**_event_payload(event), 'chat_id': chat_id}
|
||||
)
|
||||
|
||||
async def target2legacy(self, event: Event) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
"""Build the legacy message event used by pipeline-based bindings."""
|
||||
eba_event = await self.target2yiri(event)
|
||||
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||
return None
|
||||
return eba_event.to_legacy_event()
|
||||
|
||||
|
||||
def _event_payload(event: Event) -> dict:
|
||||
to_dict = getattr(event, 'to_dict', None)
|
||||
if callable(to_dict):
|
||||
return dict(to_dict())
|
||||
return {}
|
||||
|
||||
|
||||
def _first_member(members: typing.Any, fallback_id: str) -> platform_entities.User:
|
||||
"""Member arrays carry only user ids; the first entry is the subject of the event."""
|
||||
for member in members or []:
|
||||
user_id = getattr(member, 'user_id', '') or ''
|
||||
if user_id:
|
||||
return platform_entities.User(id=user_id, nickname=user_id)
|
||||
return platform_entities.User(id=fallback_id, nickname=fallback_id)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,92 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: line-omni
|
||||
label:
|
||||
en_US: LINE
|
||||
zh_Hans: LINE
|
||||
zh_Hant: LINE
|
||||
description:
|
||||
en_US: LINE adapter with event-driven orchestration, receiving messages through the unified webhook.
|
||||
zh_Hans: LINE 适配器,支持事件驱动编排,通过统一 Webhook 接收消息。
|
||||
zh_Hant: LINE 適配器,支援事件驅動編排,透過統一 Webhook 接收訊息。
|
||||
icon: line.png
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||
en: https://langbot.app/docs/en/usage/platforms/line
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL into the LINE Developers console webhook settings and enable "Use webhook".
|
||||
zh_Hans: 将此地址填入 LINE Developers 控制台的 Webhook 设置,并启用「Use webhook」。
|
||||
zh_Hant: 將此位址填入 LINE Developers 主控台的 Webhook 設定,並啟用「Use webhook」。
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: channel_access_token
|
||||
label:
|
||||
en_US: Channel Access Token
|
||||
zh_Hans: 频道访问令牌
|
||||
zh_Hant: 頻道存取權杖
|
||||
description:
|
||||
en_US: Long-lived channel access token used to send messages.
|
||||
zh_Hans: 用于发送消息的长期频道访问令牌。
|
||||
zh_Hant: 用於傳送訊息的長期頻道存取權杖。
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: channel_secret
|
||||
label:
|
||||
en_US: Channel Secret
|
||||
zh_Hans: 频道密钥
|
||||
zh_Hant: 頻道密鑰
|
||||
description:
|
||||
en_US: Channel secret used to verify the X-Line-Signature of incoming webhooks.
|
||||
zh_Hans: 用于校验入站 Webhook 的 X-Line-Signature 的频道密钥。
|
||||
zh_Hant: 用於校驗入站 Webhook 的 X-Line-Signature 的頻道密鑰。
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.deleted
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- get_user_info
|
||||
- get_group_info
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_bot_info
|
||||
description: { en_US: "Return the LINE Official Account profile", zh_Hans: "返回 LINE 官方账号资料" }
|
||||
- action: get_profile
|
||||
description: { en_US: "Call the LINE get profile API for a user", zh_Hans: "调用 LINE 用户资料接口" }
|
||||
- action: get_group_summary
|
||||
description: { en_US: "Call the LINE group summary API", zh_Hans: "调用 LINE 群组摘要接口" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: LineAdapter
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from linebot.v3.messaging import ImageMessage, TextMessage
|
||||
from linebot.v3.webhooks import (
|
||||
AudioMessageContent,
|
||||
FileMessageContent,
|
||||
ImageMessageContent,
|
||||
LocationMessageContent,
|
||||
MessageEvent,
|
||||
StickerMessageContent,
|
||||
TextMessageContent,
|
||||
UserMentionee,
|
||||
VideoMessageContent,
|
||||
)
|
||||
|
||||
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
|
||||
from langbot.pkg.platform.adapters.line.types import MAX_LINE_MEDIA_BYTES, MAX_TEXT_LENGTH
|
||||
|
||||
# LINE stores inbound images as JPEG and the Blob API does not return the media type.
|
||||
IMAGE_MIME = 'image/jpeg'
|
||||
|
||||
|
||||
def _validate_media(content: bytes) -> bytes:
|
||||
"""Reject inbound media above the LINE download cap."""
|
||||
if len(content) > MAX_LINE_MEDIA_BYTES:
|
||||
raise ValueError(f'LINE media exceeds the {MAX_LINE_MEDIA_BYTES}-byte limit')
|
||||
return content
|
||||
|
||||
|
||||
def _split_text(text: str) -> list[str]:
|
||||
"""Split a text body into LINE-sized chunks so long answers still send."""
|
||||
return [text[index : index + MAX_TEXT_LENGTH] for index in range(0, len(text), MAX_TEXT_LENGTH)]
|
||||
|
||||
|
||||
def _message_datetime(event: MessageEvent) -> datetime.datetime:
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(float(event.timestamp) / 1000.0)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return datetime.datetime.now()
|
||||
|
||||
|
||||
def _text_components(text: str, mention: typing.Any, bot_account_id: str) -> list[platform_message.MessageComponent]:
|
||||
"""Map LINE mention positions onto At components, as the legacy adapter does."""
|
||||
if not mention or not mention.mentionees:
|
||||
return [platform_message.Plain(text=text)] if text else []
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
segments = sorted(
|
||||
(mentionee.index, mentionee.index + mentionee.length, mentionee) for mentionee in mention.mentionees
|
||||
)
|
||||
cursor = 0
|
||||
for start, end, mentionee in segments:
|
||||
if start < cursor:
|
||||
start, end = cursor, min(end, len(text))
|
||||
if start < cursor or end <= start or end > len(text):
|
||||
continue
|
||||
if start > cursor:
|
||||
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||
token = text[start:end]
|
||||
if isinstance(mentionee, UserMentionee):
|
||||
target = bot_account_id if mentionee.is_self else (mentionee.user_id or token)
|
||||
else:
|
||||
target = token
|
||||
components.append(platform_message.At(target=str(target), display=token.lstrip('@')))
|
||||
cursor = end
|
||||
if cursor < len(text):
|
||||
components.append(platform_message.Plain(text=text[cursor:]))
|
||||
return components
|
||||
|
||||
|
||||
async def _download_content(blob_api: typing.Any, message_id: str) -> bytes:
|
||||
"""Fetch inbound media through the LINE Blob API without blocking the event loop."""
|
||||
import asyncio
|
||||
|
||||
if blob_api is None:
|
||||
raise NotSupportedError('line:media_download')
|
||||
return await asyncio.to_thread(blob_api.get_message_content, message_id)
|
||||
|
||||
|
||||
class LineMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert between LangBot message chains and LINE Messaging API messages."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[typing.Any]:
|
||||
"""Build the LINE message array for one push/reply request."""
|
||||
messages: list[typing.Any] = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Source):
|
||||
continue
|
||||
if isinstance(component, platform_message.Plain):
|
||||
messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text or ''))
|
||||
elif isinstance(component, platform_message.At):
|
||||
messages.append(TextMessage(text=f'@{component.display or component.target}'))
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
messages.append(TextMessage(text='@All'))
|
||||
elif isinstance(component, platform_message.Image):
|
||||
url = component.url or ''
|
||||
if not url:
|
||||
# LINE fetches outgoing images from a public URL; inline base64 is not supported.
|
||||
raise NotSupportedError('send_message:line_image_requires_public_url')
|
||||
messages.append(ImageMessage(original_content_url=url, preview_image_url=url))
|
||||
elif isinstance(component, platform_message.Quote):
|
||||
if component.origin:
|
||||
messages.extend(await LineMessageConverter.yiri2target(component.origin))
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
messages.extend(await LineMessageConverter.yiri2target(node.message_chain))
|
||||
elif isinstance(component, platform_message.Unknown):
|
||||
if component.text:
|
||||
messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text))
|
||||
else:
|
||||
raise NotSupportedError(f'send_message:line_unsupported_component:{type(component).__name__}')
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
event: MessageEvent,
|
||||
blob_api: typing.Any = None,
|
||||
bot_account_id: str = '',
|
||||
) -> platform_message.MessageChain:
|
||||
"""Build a LangBot message chain from a LINE message event."""
|
||||
message = event.message
|
||||
components: list[platform_message.MessageComponent] = [
|
||||
platform_message.Source(
|
||||
id=str(getattr(message, 'id', '') or event.webhook_event_id), time=_message_datetime(event)
|
||||
)
|
||||
]
|
||||
if isinstance(message, TextMessageContent):
|
||||
components.extend(_text_components(message.text, getattr(message, 'mention', None), bot_account_id))
|
||||
elif isinstance(message, ImageMessageContent):
|
||||
encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii')
|
||||
components.append(platform_message.Image(base64=f'data:{IMAGE_MIME};base64,{encoded}'))
|
||||
elif isinstance(message, AudioMessageContent):
|
||||
encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii')
|
||||
components.append(platform_message.Voice(base64=encoded, length=int(getattr(message, 'duration', 0) or 0)))
|
||||
elif isinstance(message, VideoMessageContent):
|
||||
components.append(platform_message.Unknown(text='[video]'))
|
||||
elif isinstance(message, StickerMessageContent):
|
||||
components.append(platform_message.Unknown(text='[sticker]'))
|
||||
elif isinstance(message, FileMessageContent):
|
||||
components.append(platform_message.Unknown(text=f'[file] {getattr(message, "file_name", "")}'.strip()))
|
||||
elif isinstance(message, LocationMessageContent):
|
||||
components.append(
|
||||
platform_message.Unknown(
|
||||
text=f'[location] {getattr(message, "title", "")} {getattr(message, "address", "")}'.strip()
|
||||
)
|
||||
)
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]'))
|
||||
if len(components) == 1:
|
||||
components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]'))
|
||||
return platform_message.MessageChain(components)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
async def get_bot_info(adapter, params: dict) -> dict:
|
||||
import asyncio
|
||||
|
||||
info = await asyncio.to_thread(adapter.bot.get_bot_info)
|
||||
return {
|
||||
'user_id': info.user_id,
|
||||
'basic_id': info.basic_id,
|
||||
'display_name': info.display_name,
|
||||
'chat_mode': info.chat_mode,
|
||||
}
|
||||
|
||||
|
||||
async def get_profile(adapter, params: dict) -> dict:
|
||||
user_id = str(params.get('user_id', ''))
|
||||
if not user_id:
|
||||
raise ValueError('get_profile requires user_id')
|
||||
import asyncio
|
||||
|
||||
profile = await asyncio.to_thread(adapter.bot.get_profile, user_id)
|
||||
return {
|
||||
'user_id': profile.user_id,
|
||||
'display_name': profile.display_name,
|
||||
'picture_url': profile.picture_url or '',
|
||||
'status_message': profile.status_message or '',
|
||||
}
|
||||
|
||||
|
||||
async def get_group_summary(adapter, params: dict) -> dict:
|
||||
group_id = str(params.get('group_id', ''))
|
||||
if not group_id:
|
||||
raise ValueError('get_group_summary requires group_id')
|
||||
import asyncio
|
||||
|
||||
summary = await asyncio.to_thread(adapter.bot.get_group_summary, group_id)
|
||||
return {
|
||||
'group_id': summary.group_id,
|
||||
'group_name': summary.group_name,
|
||||
'picture_url': summary.picture_url or '',
|
||||
}
|
||||
|
||||
|
||||
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
|
||||
'get_bot_info': get_bot_info,
|
||||
'get_profile': get_profile,
|
||||
'get_group_summary': get_group_summary,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'line-omni'
|
||||
|
||||
# LINE Messaging API limits (push/reply share the same message array budget).
|
||||
MAX_MESSAGES_PER_REQUEST = 5
|
||||
|
||||
# A single text message body is limited to 5000 characters.
|
||||
MAX_TEXT_LENGTH = 5000
|
||||
|
||||
# LINE serves inbound media through the Blob API; keep the same cap as the legacy adapter.
|
||||
MAX_LINE_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
@@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.secret_managers.main import get_secret, get_secret_bool
|
||||
|
||||
from .. import errors, reasoning, requester
|
||||
from . import minimax
|
||||
from ....utils import httpclient, image
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -213,6 +214,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
'bailian-chat-completions': 'qwen',
|
||||
'doubao-chat-completions': 'doubao',
|
||||
'mimo-chat-completions': 'mimo',
|
||||
'minimax-chat-completions': 'minimax',
|
||||
}
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
@@ -569,6 +571,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None:
|
||||
normalized_name = model_name.lower().rsplit('/', 1)[-1]
|
||||
|
||||
if family == 'minimax':
|
||||
return minimax.reasoning_levels(normalized_name)
|
||||
|
||||
if family == 'deepseek' and normalized_name.startswith('deepseek-'):
|
||||
if normalized_name.startswith('deepseek-v4-'):
|
||||
return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
|
||||
@@ -728,6 +733,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
raise errors.RequesterError(str(exc), code='invalid_policy') from exc
|
||||
|
||||
family = self._reasoning_family(model.model_entity.name, model)
|
||||
if family == 'minimax':
|
||||
return minimax.reasoning_parameters(level)
|
||||
budget = config.get('budget_tokens')
|
||||
if budget is not None:
|
||||
if self._uses_openrouter_protocol():
|
||||
@@ -837,6 +844,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
for m in messages:
|
||||
msg_dict = m.dict(exclude_none=True)
|
||||
provider_fields = msg_dict.get('provider_specific_fields')
|
||||
replay_fields = minimax.take_replay_fields(provider_fields) if isinstance(provider_fields, dict) else {}
|
||||
if isinstance(provider_fields, dict):
|
||||
# Stream completion metadata belongs to the response, never to
|
||||
# replayed chat messages. Keep provider signatures for LiteLLM.
|
||||
@@ -878,6 +886,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
else:
|
||||
msg_dict.pop('provider_specific_fields', None)
|
||||
|
||||
if msg_dict.get('role') == 'assistant' and reasoning_family == 'minimax':
|
||||
msg_dict.update(replay_fields)
|
||||
|
||||
if isinstance(content, list):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
@@ -1476,6 +1487,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
content = message_data.get('content', '')
|
||||
reasoning_content = message_data.get('reasoning_content', None)
|
||||
thinking_blocks = message_data.get('thinking_blocks')
|
||||
if self._reasoning_family(model.model_entity.name, model) == 'minimax':
|
||||
message_data['provider_specific_fields'] = minimax.capture_content(message_data)
|
||||
if reasoning_content or thinking_blocks:
|
||||
provider_fields = dict(message_data.get('provider_specific_fields') or {})
|
||||
if reasoning_content:
|
||||
@@ -1520,6 +1533,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
reasoning_started = False
|
||||
reasoning_closed = False
|
||||
thinking_blocks_state: list[dict[str, typing.Any]] = []
|
||||
keep_minimax_content = self._reasoning_family(model.model_entity.name, model) == 'minimax'
|
||||
raw_content_parts: list[str] = []
|
||||
response = None
|
||||
|
||||
async with self._client_args(args) as args:
|
||||
@@ -1562,6 +1577,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
delta_content = delta.get('content') or ''
|
||||
reasoning_content = delta.get('reasoning_content') or ''
|
||||
provider_fields = dict(delta.get('provider_specific_fields') or {})
|
||||
if keep_minimax_content:
|
||||
if isinstance(delta.get('content'), str):
|
||||
raw_content_parts.append(delta['content'])
|
||||
if finish_reason and raw_content_parts:
|
||||
provider_fields[minimax.RAW_CONTENT_FIELD] = ''.join(raw_content_parts)
|
||||
raw_content_parts.clear()
|
||||
if finish_reason:
|
||||
# Preserve an explicit provider stop even when the final delta is empty.
|
||||
provider_fields['finish_reason'] = finish_reason
|
||||
@@ -1637,6 +1658,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
yield provider_message.MessageChunk(**chunk_data)
|
||||
chunk_idx += 1
|
||||
|
||||
if raw_content_parts:
|
||||
yield provider_message.MessageChunk(
|
||||
role=role,
|
||||
is_final=True,
|
||||
provider_specific_fields={minimax.RAW_CONTENT_FIELD: ''.join(raw_content_parts)},
|
||||
)
|
||||
|
||||
if reasoning_started and not reasoning_closed:
|
||||
yield provider_message.MessageChunk(
|
||||
role=role,
|
||||
@@ -1882,8 +1910,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
# rather than /models. Providers whose configured base_url
|
||||
# already ends in /v1 keep their original (working) URL.
|
||||
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = await httpclient.parse_json_response(response)
|
||||
if self._requester_name() == 'minimax-chat-completions' and response.status_code == 404:
|
||||
payload = {}
|
||||
else:
|
||||
response.raise_for_status()
|
||||
payload = await httpclient.parse_json_response(response)
|
||||
|
||||
models = []
|
||||
for item in payload.get('data', []):
|
||||
@@ -1895,6 +1926,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
|
||||
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
|
||||
|
||||
direct_minimax = self._requester_name() == 'minimax-chat-completions'
|
||||
if direct_minimax or self._uses_openrouter_protocol():
|
||||
models = minimax.arrange_catalog(models, direct=direct_minimax)
|
||||
|
||||
return {'models': models}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
RECOMMENDED_MODELS = ('MiniMax-M3.1-Flash', 'MiniMax-M3')
|
||||
RAW_CONTENT_FIELD = 'minimax_raw_content'
|
||||
_LEGACY_TEXT_MODEL = re.compile(r'^(minimax-(m[12](?:[.\-]|$)|01(?:[.\-]|$))|abab)')
|
||||
|
||||
|
||||
def reasoning_levels(model_name: str) -> list[str] | None:
|
||||
name = model_name.lower()
|
||||
if name == RECOMMENDED_MODELS[0].lower():
|
||||
return ['provider_default', 'enabled', 'low', 'medium', 'high', 'xhigh', 'max']
|
||||
if name == RECOMMENDED_MODELS[1].lower():
|
||||
return ['provider_default', 'enabled', 'disabled']
|
||||
return None
|
||||
|
||||
|
||||
def reasoning_parameters(level: str) -> dict[str, Any]:
|
||||
if level == 'provider_default':
|
||||
return {}
|
||||
thinking_type = {'enabled': 'adaptive', 'disabled': 'disabled'}.get(level)
|
||||
if thinking_type is not None:
|
||||
return {'extra_body': {'thinking': {'type': thinking_type}}}
|
||||
return {'reasoning_effort': level}
|
||||
|
||||
|
||||
def capture_content(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
fields = dict(payload.get('provider_specific_fields') or {})
|
||||
if isinstance(payload.get('content'), str):
|
||||
fields[RAW_CONTENT_FIELD] = payload['content']
|
||||
return fields
|
||||
|
||||
|
||||
def take_replay_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
||||
replay = {}
|
||||
raw_content = fields.pop(RAW_CONTENT_FIELD, None)
|
||||
if isinstance(raw_content, str):
|
||||
replay['content'] = raw_content
|
||||
if isinstance(fields.get('reasoning_content'), str):
|
||||
replay['reasoning_content'] = fields['reasoning_content']
|
||||
return replay
|
||||
|
||||
|
||||
def arrange_catalog(models: list[dict[str, Any]], *, direct: bool) -> list[dict[str, Any]]:
|
||||
priorities = {name.lower(): position for position, name in enumerate(RECOMMENDED_MODELS)}
|
||||
|
||||
def model_name(model: dict[str, Any]) -> str:
|
||||
name = model['id'].lower()
|
||||
if not direct:
|
||||
if not name.startswith('minimax/'):
|
||||
return ''
|
||||
name = name.removeprefix('minimax/')
|
||||
return name.partition(':')[0]
|
||||
|
||||
catalog = [
|
||||
dict(model) for model in models if model['type'] != 'llm' or not _LEGACY_TEXT_MODEL.match(model_name(model))
|
||||
]
|
||||
if direct:
|
||||
present = {model['id'].lower() for model in catalog}
|
||||
catalog.extend(
|
||||
{'id': name, 'name': name, 'type': 'llm'} for name in RECOMMENDED_MODELS if name.lower() not in present
|
||||
)
|
||||
for model in catalog:
|
||||
if model_name(model) in priorities:
|
||||
model.setdefault('context_length', 1_000_000)
|
||||
model['abilities'] = list(
|
||||
dict.fromkeys([*model.get('abilities', []), 'func_call', 'vision', 'reasoning'])
|
||||
)
|
||||
return sorted(catalog, key=lambda model: priorities.get(model_name(model), len(priorities)))
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.minimax.chat/v1
|
||||
default: https://api.minimax.io/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
|
||||
Reference in New Issue
Block a user