diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index fc66b27819..c8920038ff 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1206,6 +1206,20 @@ "custom_headers": {"User-Agent": "claude-code/0.1.0"}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, + "OpenCode Go": { + "id": "opencode-go", + "provider": "opencode-go", + "type": "opencode_go_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://opencode.ai/zen/go/v1", + "model": "opencode-go/kimi-k2.6", + "timeout": 120, + "proxy": "", + "custom_headers": {}, + "force_tool_call_reasoning_content": True, + }, "Moonshot": { "id": "moonshot", "provider": "moonshot", @@ -1217,6 +1231,7 @@ "api_base": "https://api.moonshot.cn/v1", "proxy": "", "custom_headers": {}, + "force_tool_call_reasoning_content": True, }, "MiniMax": { "id": "minimax", @@ -1969,6 +1984,11 @@ "type": "bool", "hint": "关闭 Ollama 思考模式。", }, + "force_tool_call_reasoning_content": { + "description": "工具调用历史强制保留思考内容", + "type": "bool", + "hint": "部分兼容 OpenAI 的模型服务在启用思考模式后,要求 assistant 工具调用历史包含 reasoning_content。", + }, "custom_extra_body": { "description": "自定义请求体参数", "type": "dict", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 333cf6c906..8980c89fa1 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -361,6 +361,10 @@ def dynamic_import_provider(self, type: str) -> None: from .sources.openai_source import ( ProviderOpenAIOfficial as ProviderOpenAIOfficial, ) + case "opencode_go_chat_completion": + from .sources.opencode_go_source import ( + ProviderOpenCodeGo as ProviderOpenCodeGo, + ) case "longcat_chat_completion": from .sources.longcat_source import ProviderLongCat as ProviderLongCat case "minimax_token_plan": diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 64e3a6645a..c3354abf42 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -515,6 +515,41 @@ def _apply_provider_specific_extra_body_overrides( extra_body.pop("think", None) extra_body["reasoning_effort"] = "none" + def _requires_tool_call_reasoning_content( + self, + payloads: dict, + extra_body: dict[str, Any], + ) -> bool: + thinking = extra_body.get("thinking") + if isinstance(thinking, dict) and thinking.get("type") == "disabled": + return False + + value = self.provider_config.get("force_tool_call_reasoning_content", False) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + def _ensure_tool_call_reasoning_content( + self, + payloads: dict, + extra_body: dict[str, Any], + ) -> None: + if not self._requires_tool_call_reasoning_content(payloads, extra_body): + return + + messages = payloads.get("messages") + if not isinstance(messages, list): + return + + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") != "assistant" or not message.get("tool_calls"): + continue + reasoning_content = message.get("reasoning_content") + if not isinstance(reasoning_content, str) or not reasoning_content.strip(): + message["reasoning_content"] = " " + async def get_models(self): try: models_str = [] @@ -591,6 +626,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: model = payloads.get("model", "").lower() + self._ensure_tool_call_reasoning_content(payloads, extra_body) self._sanitize_assistant_messages(payloads) completion = await self.client.chat.completions.create( @@ -643,6 +679,7 @@ async def _query_stream( del payloads[key] self._apply_provider_specific_extra_body_overrides(extra_body) + self._ensure_tool_call_reasoning_content(payloads, extra_body) self._sanitize_assistant_messages(payloads) stream = await self.client.chat.completions.create( diff --git a/astrbot/core/provider/sources/opencode_go_source.py b/astrbot/core/provider/sources/opencode_go_source.py new file mode 100644 index 0000000000..c8a86d723f --- /dev/null +++ b/astrbot/core/provider/sources/opencode_go_source.py @@ -0,0 +1,154 @@ +from collections.abc import AsyncGenerator +from typing import Literal + +from astrbot.api.provider import Provider +from astrbot.core.agent.message import ContentPart, Message +from astrbot.core.agent.tool import ToolSet +from astrbot.core.provider.entities import LLMResponse, ToolCallsResult + +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + +OPENCODE_GO_API_BASE = "https://opencode.ai/zen/go/v1" +OPENCODE_GO_MODEL_PREFIX = "opencode-go/" +OPENCODE_GO_DEFAULT_MODEL = "kimi-k2.6" +OPENCODE_GO_MESSAGES_ONLY_MODELS = {"minimax-m2.5", "minimax-m2.7"} + + +@register_provider_adapter( + "opencode_go_chat_completion", + "OpenCode Go Subscription Provider Adapter", +) +class ProviderOpenCodeGo(Provider): + def __init__(self, provider_config: dict, provider_settings: dict) -> None: + super().__init__(provider_config, provider_settings) + self.api_base = provider_config.get("api_base", OPENCODE_GO_API_BASE).rstrip( + "/" + ) + self.timeout = provider_config.get("timeout", 120) + if isinstance(self.timeout, str): + self.timeout = int(self.timeout) + + model = self._to_api_model( + provider_config.get("model", OPENCODE_GO_DEFAULT_MODEL) + ) + self.set_model(model) + + self.openai_provider = ProviderOpenAIOfficial( + self._build_delegate_config(model=model), + provider_settings, + ) + + def _build_delegate_config(self, *, model: str) -> dict: + config = dict(self.provider_config) + config["api_base"] = self.api_base + config["model"] = model + config["force_tool_call_reasoning_content"] = True + return config + + @classmethod + def _to_api_model(cls, model: str | None) -> str: + resolved_model = (model or OPENCODE_GO_DEFAULT_MODEL).strip() + if resolved_model.startswith(OPENCODE_GO_MODEL_PREFIX): + return resolved_model.removeprefix(OPENCODE_GO_MODEL_PREFIX) + return resolved_model + + @classmethod + def _to_provider_model(cls, model: str) -> str: + api_model = cls._to_api_model(model) + return f"{OPENCODE_GO_MODEL_PREFIX}{api_model}" + + @classmethod + def _ensure_chat_completions_model(cls, model: str | None) -> str: + api_model = cls._to_api_model(model) + if api_model in OPENCODE_GO_MESSAGES_ONLY_MODELS: + raise ValueError( + f"OpenCode Go model {OPENCODE_GO_MODEL_PREFIX}{api_model} uses " + "/v1/messages. This adapter currently supports " + "/v1/chat/completions models only." + ) + return api_model + + def _resolve_model(self, model: str | None = None) -> str: + return self._ensure_chat_completions_model(model or self.get_model()) + + def get_current_key(self) -> str: + return self.openai_provider.get_current_key() + + def get_keys(self) -> list[str]: + return self.openai_provider.get_keys() + + def set_key(self, key: str) -> None: + self.openai_provider.set_key(key) + + async def get_models(self) -> list[str]: + models = await self.openai_provider.get_models() + provider_models: list[str] = [] + for model in models: + api_model = self._to_api_model(model) + if not api_model or api_model in OPENCODE_GO_MESSAGES_ONLY_MODELS: + continue + provider_models.append(f"{OPENCODE_GO_MODEL_PREFIX}{api_model}") + return sorted(provider_models) + + async def text_chat( + self, + prompt: str | None = None, + session_id: str | None = None, + image_urls: list[str] | None = None, + audio_urls: list[str] | None = None, + func_tool: ToolSet | None = None, + contexts: list[Message] | list[dict] | None = None, + system_prompt: str | None = None, + tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, + model: str | None = None, + extra_user_content_parts: list[ContentPart] | None = None, + tool_choice: Literal["auto", "required"] = "auto", + **kwargs, + ) -> LLMResponse: + return await self.openai_provider.text_chat( + prompt=prompt, + session_id=session_id, + image_urls=image_urls, + audio_urls=audio_urls, + func_tool=func_tool, + contexts=contexts, + system_prompt=system_prompt, + tool_calls_result=tool_calls_result, + model=self._resolve_model(model), + extra_user_content_parts=extra_user_content_parts, + tool_choice=tool_choice, + **kwargs, + ) + + async def text_chat_stream( + self, + prompt: str | None = None, + session_id: str | None = None, + image_urls: list[str] | None = None, + audio_urls: list[str] | None = None, + func_tool: ToolSet | None = None, + contexts: list[Message] | list[dict] | None = None, + system_prompt: str | None = None, + tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, + model: str | None = None, + tool_choice: Literal["auto", "required"] = "auto", + **kwargs, + ) -> AsyncGenerator[LLMResponse, None]: + async for response in self.openai_provider.text_chat_stream( + prompt=prompt, + session_id=session_id, + image_urls=image_urls, + audio_urls=audio_urls, + func_tool=func_tool, + contexts=contexts, + system_prompt=system_prompt, + tool_calls_result=tool_calls_result, + model=self._resolve_model(model), + tool_choice=tool_choice, + **kwargs, + ): + yield response + + async def terminate(self) -> None: + await self.openai_provider.terminate() diff --git a/dashboard/src/assets/images/provider_logos/opencode-go.png b/dashboard/src/assets/images/provider_logos/opencode-go.png new file mode 100644 index 0000000000..15266d28f1 Binary files /dev/null and b/dashboard/src/assets/images/provider_logos/opencode-go.png differ diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index a85ef2ae52..94c0da6504 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -89,7 +89,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { types.push({ value: templateName, label: templateName, - icon: getProviderIcon(template.provider) + icon: getProviderIcon(template.provider || template.id || template.type || templateName) }) } } @@ -272,7 +272,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { function resolveSourceIcon(source: any) { if (!source) return '' - return getProviderIcon(source.provider) || '' + return getProviderIcon(source.provider || source.id || source.type || source.templateKey) || '' } function getSourceDisplayName(source: any) { @@ -543,7 +543,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) { if (!selectedProviderSource.value) return const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id - const newId = `${sourceId}/${modelName}` + const newId = modelName.startsWith(`${sourceId}/`) + ? modelName + : `${sourceId}/${modelName}` const metadata = getModelMetadata(modelName) let modalities: string[] diff --git a/dashboard/src/utils/providerUtils.js b/dashboard/src/utils/providerUtils.js index dbf09b83a3..c9bd592c51 100644 --- a/dashboard/src/utils/providerUtils.js +++ b/dashboard/src/utils/providerUtils.js @@ -2,12 +2,15 @@ * 提供商相关的工具函数 */ +const opencodeGoIcon = new URL('@/assets/images/provider_logos/opencode-go.png', import.meta.url).href; + /** * 获取提供商类型对应的图标 * @param {string} type - 提供商类型 * @returns {string} 图标 URL */ export function getProviderIcon(type) { + const providerType = type?.toString().trim().toLowerCase(); const icons = { 'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg', 'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg', @@ -23,6 +26,8 @@ export function getProviderIcon(type) { 'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', 'kimi': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', 'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', + 'opencode-go': opencodeGoIcon, + 'opencode_go_chat_completion': opencodeGoIcon, 'longcat': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg', 'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg', 'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg', @@ -47,7 +52,7 @@ export function getProviderIcon(type) { "bailian": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/bailian-color.svg", "volcengine": 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/volcengine-color.svg', }; - return icons[type] || ''; + return icons[providerType] || ''; } /** diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 950e2ea162..48d34e7293 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1,4 +1,5 @@ import builtins +import importlib from types import SimpleNamespace import pytest @@ -10,6 +11,7 @@ from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.sources.groq_source import ProviderGroq from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial +from astrbot.core.provider.sources.opencode_go_source import ProviderOpenCodeGo class _ErrorWithBody(Exception): @@ -24,6 +26,22 @@ def __init__(self, message: str, response_text: str): self.response = SimpleNamespace(text=response_text) +class _ModelsProviderStub: + def __init__(self, models: list[str]): + self._models = models + + async def get_models(self) -> list[str]: + return self._models + + +class _OpenCodeGoUnitProvider(ProviderOpenCodeGo): + openai_provider: _ModelsProviderStub + + def __init__(self, models: list[str]): + self.openai_provider = _ModelsProviderStub(models) + self.model_name = "kimi-k2.6" + + def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial: provider_config = { "id": "test-openai", @@ -54,6 +72,12 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq: ) +def _make_opencode_go_provider_for_unit_tests( + models: list[str] | None = None, +) -> ProviderOpenCodeGo: + return _OpenCodeGoUnitProvider(models or []) + + def test_create_http_client_uses_openai_httpx_module(monkeypatch): captured: dict[str, object] = {} @@ -76,7 +100,7 @@ def fake_create_proxy_client( provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial) provider._create_http_client({"proxy": ""}) - from openai import _base_client as openai_base_client + openai_base_client = importlib.import_module("openai._base_client") assert captured["httpx_module"] is openai_base_client.httpx @@ -335,6 +359,109 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history(): await provider.terminate() +@pytest.mark.asyncio +async def test_force_tool_call_reasoning_content_config_adds_missing_value(): + provider = _make_provider({"force_tool_call_reasoning_content": True}) + try: + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call-1", "type": "function"}], + } + ] + } + + provider._ensure_tool_call_reasoning_content(payloads, {}) + + assert payloads["messages"][0]["reasoning_content"] == " " + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_force_tool_call_reasoning_content_config_respects_disabled_thinking(): + provider = _make_provider({"force_tool_call_reasoning_content": True}) + try: + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call-1", "type": "function"}], + } + ] + } + + provider._ensure_tool_call_reasoning_content( + payloads, + {"thinking": {"type": "disabled"}}, + ) + + assert "reasoning_content" not in payloads["messages"][0] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_moonshot_provider_name_without_config_does_not_force_reasoning_content(): + provider = _make_provider({"provider": "moonshot"}) + try: + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call-1", "type": "function"}], + } + ] + } + + provider._ensure_tool_call_reasoning_content(payloads, {}) + + assert "reasoning_content" not in payloads["messages"][0] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_opencode_go_get_models_prefixes_and_filters_chat_models_once(): + provider = _make_opencode_go_provider_for_unit_tests( + [ + "kimi-k2.6", + "opencode-go/kimi-k2.5", + "minimax-m2.5", + "opencode-go/minimax-m2.7", + " ", + ] + ) + call_count = 0 + original_to_api_model = ProviderOpenCodeGo._to_api_model + + def counting_to_api_model(model: str | None) -> str: + nonlocal call_count + call_count += 1 + return original_to_api_model(model) + + provider._to_api_model = counting_to_api_model + + assert await provider.get_models() == [ + "opencode-go/kimi-k2.5", + "opencode-go/kimi-k2.6", + ] + assert call_count == 5 + + +def test_opencode_go_resolve_model_strips_prefix_and_rejects_messages_only_model(): + provider = _make_opencode_go_provider_for_unit_tests() + + assert provider._resolve_model("opencode-go/kimi-k2.6") == "kimi-k2.6" + assert provider._resolve_model(None) == "kimi-k2.6" + with pytest.raises(ValueError, match="/v1/messages"): + provider._resolve_model("opencode-go/minimax-m2.5") + + @pytest.mark.asyncio async def test_handle_api_error_content_moderated_without_images_raises(): provider = _make_provider(