From 36bfe3a4490259eb8f89a84b7a9cad2a7c4de8ab Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:51:04 -0700 Subject: [PATCH] fix(anthropic+feishu): model-gate max_tokens fallback; wire Feishu channel_prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes salvaged from #12811 (closing it; one of its three bundled fixes — Discord free_response — is already on main). Anthropic max_tokens (#12790): the chat-completions max_tokens fallback only fired for OpenRouter/Nous URLs, so any other proxy serving a Claude model (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) shipped requests with no max_tokens and inherited the proxy's low default (Bedrock: 4096), exhausting on thinking + large tool calls. Changed the gate in chat_completion_helpers.build_api_kwargs from URL-gated to model-gated: fires whenever the model matches an _ANTHROPIC_OUTPUT_LIMITS key. This also fixes a latent miss — the old 'claude' substring gate skipped MiniMax and Qwen3 even on OpenRouter. Remains a last-resort fallback (build_kwargs only applies it after ephemeral/user/profile max_tokens), so it never overrides an explicit value, and only touches the chat-completions transport (native Anthropic Messages API is a separate path). Feishu channel_prompt (#12805): the Feishu adapter never resolved channel_prompts config, unlike Discord/Slack, so per-channel role prompts were silently ignored. Added _resolve_channel_prompt() (delegating to the shared gateway.platforms.base.resolve_channel_prompt) and wired it into all three MessageEvent construction sites — inbound message, reaction routing, and card-action routing. Tests: tests/gateway/test_feishu_channel_prompts.py (6 cases) covering exact match, parent-thread fallback, no-match, missing-config safety, and event propagation. --- agent/chat_completion_helpers.py | 24 ++++-- plugins/platforms/feishu/adapter.py | 15 ++++ tests/gateway/test_feishu_channel_prompts.py | 88 ++++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_feishu_channel_prompts.py diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 87d3d6c4dbb..56228ac0924 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -741,14 +741,26 @@ def build_api_kwargs(agent, api_messages: list) -> dict: if agent.provider_data_collection: _prefs["data_collection"] = agent.provider_data_collection - # Claude max-output override on aggregators + # Anthropic-compatible max-output fallback (last resort only — applied in + # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding + # an explicit value). Model-gated, not URL-gated: any chat-completions + # proxy serving a Claude/MiniMax/Qwen3 model needs max_tokens, because the + # Anthropic Messages API treats it as mandatory and proxies that omit it + # (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) default as low + # as 4096 output tokens — easily exhausted by thinking + large tool calls + # like write_file/patch. OpenRouter/Nous were the only routes covered + # before; gating on _ANTHROPIC_OUTPUT_LIMITS membership covers them all. _ant_max = None - if (_is_or or _is_nous) and "claude" in (agent.model or "").lower(): - try: - from agent.anthropic_adapter import _get_anthropic_max_output + try: + from agent.anthropic_adapter import ( + _get_anthropic_max_output, + _ANTHROPIC_OUTPUT_LIMITS, + ) + _model_norm = (agent.model or "").lower().replace(".", "-") + if any(key in _model_norm for key in _ANTHROPIC_OUTPUT_LIMITS): _ant_max = _get_anthropic_max_output(agent.model) - except Exception: - pass + except Exception: + pass # Qwen session metadata _qwen_meta = None diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 9b92d2d8677..12b6e3e43c7 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -2892,6 +2892,7 @@ class FeishuAdapter(BasePlatformAdapter): source=source, raw_message=data, message_id=message_id, + channel_prompt=self._resolve_channel_prompt(chat_id), timestamp=datetime.now(), ) logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id) @@ -2954,6 +2955,7 @@ class FeishuAdapter(BasePlatformAdapter): source=source, raw_message=data, message_id=token or str(uuid.uuid4()), + channel_prompt=self._resolve_channel_prompt(chat_id), timestamp=datetime.now(), ) logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id) @@ -3156,6 +3158,18 @@ class FeishuAdapter(BasePlatformAdapter): # Inbound processing pipeline # ========================================================================= + def _resolve_channel_prompt(self, chat_id: str, parent_id: str | None = None) -> str | None: + """Resolve a Feishu per-channel system prompt. + + Mirrors the Discord/Slack behaviour so ``channel_prompts: {: + ""}`` in ``PlatformConfig.extra`` is honoured for Feishu chats + instead of being silently ignored. + """ + from gateway.platforms.base import resolve_channel_prompt + _config = getattr(self, "config", None) + _extra = getattr(_config, "extra", None) or {} + return resolve_channel_prompt(_extra, chat_id, parent_id) + async def _process_inbound_message( self, *, @@ -3233,6 +3247,7 @@ class FeishuAdapter(BasePlatformAdapter): media_types=media_types, reply_to_message_id=reply_to_message_id, reply_to_text=reply_to_text, + channel_prompt=self._resolve_channel_prompt(chat_id, thread_id or None), timestamp=datetime.now(), ) await self._dispatch_inbound_event(normalized) diff --git a/tests/gateway/test_feishu_channel_prompts.py b/tests/gateway/test_feishu_channel_prompts.py new file mode 100644 index 00000000000..31150d9154b --- /dev/null +++ b/tests/gateway/test_feishu_channel_prompts.py @@ -0,0 +1,88 @@ +"""Tests for Feishu per-channel prompt resolution. + +Feishu previously ignored ``channel_prompts`` config (unlike Discord/Slack). +These tests verify that ``_resolve_channel_prompt`` reads the adapter's +``config.extra`` and that the resolved prompt is attached to the dispatched +``MessageEvent`` for the inbound, reaction, and card-action paths. +""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from gateway.config import PlatformConfig + + +def _build_adapter(extra=None): + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter.config = PlatformConfig(extra=extra or {}) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + adapter._fetch_message_text = AsyncMock(return_value=None) + adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"}) + adapter._resolve_sender_profile = AsyncMock( + return_value={"user_id": "u1", "user_name": "Alice", "user_id_alt": None} + ) + adapter._resolve_source_chat_type = Mock(return_value="group") + adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) + adapter._dispatch_inbound_event = AsyncMock() + return adapter + + +def _run_inbound(adapter, chat_id="oc_chat"): + message = SimpleNamespace( + content=json.dumps({"text": "plain message"}), + message_type="text", + message_id="m", + mentions=[], + chat_id=chat_id, + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, chat_type="group", message_id="m", + ) + ) + return adapter._dispatch_inbound_event.call_args.args[0] + + +def test_resolve_channel_prompt_exact_match(): + adapter = _build_adapter({"channel_prompts": {"oc_chat": "Be terse."}}) + assert adapter._resolve_channel_prompt("oc_chat") == "Be terse." + + +def test_resolve_channel_prompt_parent_fallback(): + adapter = _build_adapter({"channel_prompts": {"oc_parent": "Inherit me."}}) + assert adapter._resolve_channel_prompt("oc_thread", "oc_parent") == "Inherit me." + + +def test_resolve_channel_prompt_no_match_returns_none(): + adapter = _build_adapter({"channel_prompts": {"oc_other": "Nope."}}) + assert adapter._resolve_channel_prompt("oc_chat") is None + + +def test_resolve_channel_prompt_missing_config_is_safe(): + # __new__ adapter without a config attribute (defensive getattr path). + from plugins.platforms.feishu.adapter import FeishuAdapter + + bare = FeishuAdapter.__new__(FeishuAdapter) + assert bare._resolve_channel_prompt("oc_chat") is None + + +def test_inbound_event_carries_channel_prompt(): + adapter = _build_adapter({"channel_prompts": {"oc_chat": "Feishu role prompt."}}) + event = _run_inbound(adapter, chat_id="oc_chat") + assert event.channel_prompt == "Feishu role prompt." + + +def test_inbound_event_no_prompt_when_unconfigured(): + adapter = _build_adapter({"channel_prompts": {"oc_other": "Different chat."}}) + event = _run_inbound(adapter, chat_id="oc_chat") + assert event.channel_prompt is None