diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index bdabcd933e..6ffa8fa4ea 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1225,6 +1225,7 @@ class ChatProviderTemplate(TypedDict): "api_base": "http://127.0.0.1:11434/v1", "proxy": "", "custom_headers": {}, + "ollama_disable_thinking": False, }, "LM Studio": { "id": "lm_studio", @@ -1754,6 +1755,12 @@ class ChatProviderTemplate(TypedDict): "items": {}, "hint": "此处添加的键值对将被合并到 OpenAI SDK 的 default_headers 中,用于自定义 HTTP 请求头。值必须为字符串。", }, + "ollama_disable_thinking": { + "description": "关闭思考模式", + "type": "bool", + "hint": "仅对 Ollama 提供商生效。启用后会通过 OpenAI 兼容接口注入 reasoning_effort=none,以稳定关闭 thinking;比 think:false 更可靠。", + "condition": {"provider": "ollama"}, + }, "custom_extra_body": { "description": "自定义请求体参数", "type": "dict", diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index adee24073d..9311a6e035 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -210,6 +210,26 @@ def __init__(self, provider_config, provider_settings) -> None: self.reasoning_key = "reasoning_content" + def _ollama_disable_thinking_enabled(self) -> bool: + value = self.provider_config.get("ollama_disable_thinking", False) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + def _apply_provider_specific_extra_body_overrides( + self, extra_body: dict[str, Any] + ) -> None: + if self.provider_config.get("provider") != "ollama": + return + if not self._ollama_disable_thinking_enabled(): + return + + # Ollama's OpenAI-compatible endpoint reliably maps reasoning_effort=none + # to think=false, while direct think=false passthrough is not stable. + extra_body.pop("reasoning", None) + extra_body.pop("think", None) + extra_body["reasoning_effort"] = "none" + async def get_models(self): try: models_str = [] @@ -245,6 +265,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) + self._apply_provider_specific_extra_body_overrides(extra_body) model = payloads.get("model", "").lower() @@ -295,6 +316,7 @@ async def _query_stream( to_del.append(key) for key in to_del: del payloads[key] + self._apply_provider_specific_extra_body_overrides(extra_body) stream = await self.client.chat.completions.create( **payloads, diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 97eb044da2..e8c00a79ab 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -203,11 +203,10 @@ export function useProviderSources(options: UseProviderSourcesOptions) { const advancedSourceConfig = computed(() => { if (!editableProviderSource.value) return null - const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider'] + const excluded = new Set(['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']) const advanced: Record = {} for (const key of Object.keys(editableProviderSource.value)) { - if (excluded.includes(key)) continue Object.defineProperty(advanced, key, { get() { return editableProviderSource.value![key] @@ -215,7 +214,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { set(val) { editableProviderSource.value![key] = val }, - enumerable: true + enumerable: !excluded.has(key) }) } @@ -344,7 +343,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) { selectedProviderSource.value = source selectedProviderSourceOriginalId.value = source?.id || null suppressSourceWatch = true - editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null + editableProviderSource.value = source + ? ensureProviderSourceDefaults(JSON.parse(JSON.stringify(source))) + : null nextTick(() => { suppressSourceWatch = false }) @@ -353,6 +354,18 @@ export function useProviderSources(options: UseProviderSourcesOptions) { isSourceModified.value = false } + function ensureProviderSourceDefaults(source: any) { + if (!source || typeof source !== 'object') { + return source + } + + if (source.provider === 'ollama' && source.ollama_disable_thinking === undefined) { + source.ollama_disable_thinking = false + } + + return source + } + function extractSourceFieldsFromTemplate(template: Record) { const sourceFields: Record = {} const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body'] @@ -388,14 +401,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) { } const newId = generateUniqueSourceId(template.id) - const newSource = { + const newSource = ensureProviderSourceDefaults({ ...extractSourceFieldsFromTemplate(template), id: newId, type: template.type, provider_type: template.provider_type, provider: template.provider, enable: true - } + }) providerSources.value.push(newSource) selectedProviderSource.value = newSource diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 089aca7ad4..4f979635bf 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1068,6 +1068,10 @@ "description": "Custom request headers", "hint": "Key/value pairs added here are merged into the OpenAI SDK default_headers for custom HTTP headers. Values must be strings." }, + "ollama_disable_thinking": { + "description": "Disable thinking mode", + "hint": "Only applies to the Ollama provider. When enabled, AstrBot injects reasoning_effort=none on the OpenAI-compatible endpoint, which is more reliable than think:false." + }, "custom_extra_body": { "description": "Custom request body parameters", "hint": "Add extra parameters to requests, such as temperature, top_p, max_tokens, etc.", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 158dbf3806..c3aae880cc 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1071,6 +1071,10 @@ "description": "自定义添加请求头", "hint": "此处添加的键值对将被合并到 OpenAI SDK 的 default_headers 中,用于自定义 HTTP 请求头。值必须为字符串。" }, + "ollama_disable_thinking": { + "description": "关闭思考模式", + "hint": "仅对 Ollama 提供商生效。启用后会通过 OpenAI 兼容接口注入 reasoning_effort=none,以稳定关闭 thinking;比 think:false 更可靠。" + }, "custom_extra_body": { "description": "自定义请求体参数", "hint": "用于在请求时添加额外的参数,如 temperature、top_p、max_tokens 等。", diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 3172097c72..a501f8f818 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1,6 +1,7 @@ from types import SimpleNamespace import pytest +from openai.types.chat.chat_completion import ChatCompletion from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial @@ -380,3 +381,88 @@ async def test_handle_api_error_unknown_image_error_raises(): ) finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking(): + provider = _make_provider( + { + "provider": "ollama", + "ollama_disable_thinking": True, + } + ) + try: + extra_body = { + "reasoning": {"effort": "high"}, + "reasoning_effort": "low", + "think": True, + "temperature": 0.2, + } + + provider._apply_provider_specific_extra_body_overrides(extra_body) + + assert extra_body["reasoning_effort"] == "none" + assert "reasoning" not in extra_body + assert "think" not in extra_body + assert extra_body["temperature"] == 0.2 + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_injects_reasoning_effort_none_for_ollama(monkeypatch): + provider = _make_provider( + { + "provider": "ollama", + "ollama_disable_thinking": True, + "custom_extra_body": { + "reasoning": {"effort": "high"}, + "temperature": 0.1, + }, + } + ) + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "qwen3.5:4b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + await provider._query( + payloads={ + "model": "qwen3.5:4b", + "messages": [{"role": "user", "content": "hello"}], + }, + tools=None, + ) + + extra_body = captured_kwargs["extra_body"] + assert extra_body["reasoning_effort"] == "none" + assert "reasoning" not in extra_body + assert extra_body["temperature"] == 0.1 + finally: + await provider.terminate()