补充并收敛管理台与运行时改动,完成本轮代码提交。

包含模型配置/thinking 透传、MCP 运行时与安装体验增强、以及会话渲染与图片显示链路修复,确保流式与历史展示行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-28 03:38:39 +08:00
parent d57969d66d
commit 3d27a01879
10 changed files with 395 additions and 41 deletions

View file

@ -30,6 +30,47 @@ def _is_minimax_compat(model: str | None, base_url: str | None) -> bool:
return ("minimax" in m) or ("minimax" in b)
def _truthy_env(name: str, default: str = "") -> bool:
v = os.getenv(name)
if v is None:
v = default
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
def _should_disable_thinking(base_url: str | None) -> bool:
# Some OpenAI-compatible gateways enable "thinking" mode and require replaying
# reasoning_content in subsequent turns. When the client does not preserve it,
# the gateway returns HTTP 400. Allow disabling thinking at request level.
#
# Safety: do NOT send unknown fields to official OpenAI endpoints by default.
if _truthy_env("AIA_LLM_THINKING_FORCE_DISABLED", "0"):
return True
if _truthy_env("AIA_LLM_THINKING_FORCE_ENABLED", "0"):
return False
b = str(base_url or "").strip().lower()
if not b:
return False
if "api.openai.com" in b:
return False
# Default-on for non-official OpenAI-compatible gateways.
return _truthy_env("AIA_LLM_THINKING_DISABLED", "1")
def _should_enable_thinking(base_url: str | None, *, thinking_mode_enabled: bool = False) -> bool:
if _truthy_env("AIA_LLM_THINKING_FORCE_ENABLED", "0"):
return True
if _truthy_env("AIA_LLM_THINKING_FORCE_DISABLED", "0"):
return False
if not bool(thinking_mode_enabled):
return False
b = str(base_url or "").strip().lower()
if not b:
return False
if "api.openai.com" in b:
return False
return True
def _find_thought_signature_in_obj(o: Any) -> str | None:
if isinstance(o, dict):
for k in ("thought_signature", "thoughtSignature"):
@ -102,10 +143,15 @@ class OpenAIChatModel(ChatModel):
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
thinking_mode_enabled: bool = False,
reasoning_effort: str | None = None,
):
self.model = model or os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
self.base_url = base_url or os.getenv("OPENAI_BASE_URL")
self.thinking_mode_enabled = bool(thinking_mode_enabled)
eff = str(reasoning_effort or "").strip().lower()
self.reasoning_effort = eff if eff in ("low", "medium", "high") else ""
if not self.api_key:
raise RuntimeError("未设置 OPENAI_API_KEY,无法使用 OpenAI 模型")
@ -177,6 +223,19 @@ class OpenAIChatModel(ChatModel):
cleaned_msgs.append(m)
kwargs: dict[str, Any] = {"model": self.model, "messages": cleaned_msgs, "stream": stream}
if _should_enable_thinking(self.base_url, thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False))):
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "enabled"}
kwargs["extra_body"] = extra_body
eff = str(getattr(self, "reasoning_effort", "") or "").strip().lower()
if eff in ("low", "medium", "high"):
kwargs["reasoning_effort"] = eff
elif _should_disable_thinking(self.base_url):
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
if use_tools:
try:
from oclaw.platform.config.paths import db_path
@ -195,7 +254,20 @@ class OpenAIChatModel(ChatModel):
kwargs["tools"] = plan.tools_wired
except Exception:
kwargs["tools"] = tools
return self._client.chat.completions.create(**kwargs)
try:
return self._client.chat.completions.create(**kwargs)
except Exception as exc:
msg = str(exc)
if "reasoning_content" in msg and "thinking mode" in msg and "must be passed back" in msg:
# Provider requires replaying assistant.reasoning_content in thinking mode.
# As a safety fallback, force-disable thinking and retry once.
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
kwargs.pop("reasoning_effort", None)
return self._client.chat.completions.create(**kwargs)
raise
def _llm_response_from_completion(self, completion: Any, *, on_token: Optional[Callable[[str], None]]) -> LLMResponse:
msg = completion.choices[0].message

View file

@ -104,10 +104,21 @@ def parse_openai_responses_stream_events(
class OpenAIResponsesModel(ChatModel):
"""OpenAI Responses API transport (OpenAI-compatible gateways may implement this surface)."""
def __init__(self, *, model: str | None = None, api_key: str | None = None, base_url: str | None = None):
def __init__(
self,
*,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
thinking_mode_enabled: bool = False,
reasoning_effort: str | None = None,
):
self.model = (model or os.getenv("OPENAI_MODEL") or "gpt-4o-mini").strip()
self.api_key = (api_key or os.getenv("OPENAI_API_KEY") or "").strip()
self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "").strip() or None
self.thinking_mode_enabled = bool(thinking_mode_enabled)
eff = str(reasoning_effort or "").strip().lower()
self.reasoning_effort = eff if eff in ("low", "medium", "high") else ""
if not self.api_key:
raise RuntimeError("未设置 OPENAI_API_KEY,无法使用 OpenAI Responses")
try:
@ -188,9 +199,41 @@ class OpenAIResponsesModel(ChatModel):
norm = self._normalize_messages(messages)
# OpenAI-compatible gateways differ: some require input={"messages":[...]} with role=user only.
stream_errors: list[str] = []
b = str(self.base_url or "").strip().lower()
force_disable = str(os.getenv("AIA_LLM_THINKING_FORCE_DISABLED") or "").strip().lower() in ("1", "true", "yes", "on")
force_enable = str(os.getenv("AIA_LLM_THINKING_FORCE_ENABLED") or "").strip().lower() in ("1", "true", "yes", "on")
mode_enabled = bool(getattr(self, "thinking_mode_enabled", False))
extra_body: dict[str, Any] = {}
if b and ("api.openai.com" not in b):
if force_disable:
extra_body["thinking"] = {"type": "disabled"}
elif force_enable or mode_enabled:
extra_body["thinking"] = {"type": "enabled"}
else:
# Default: disable thinking for non-official gateways unless explicitly enabled.
if str(os.getenv("AIA_LLM_THINKING_DISABLED") or "1").strip().lower() in ("1", "true", "yes", "on"):
extra_body["thinking"] = {"type": "disabled"}
thinking = {"extra_body": extra_body} if extra_body else {}
reasoning_effort = str(getattr(self, "reasoning_effort", "") or "").strip().lower()
if reasoning_effort not in ("low", "medium", "high"):
reasoning_effort = ""
stream_variants: list[dict[str, Any]] = [
{"model": self.model, "input": {"messages": norm}, "tools": tools or None, "stream": True},
{"model": self.model, "input": norm, "tools": tools or None, "stream": True},
{
**thinking,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"input": {"messages": norm},
"tools": tools or None,
"stream": True,
},
{
**thinking,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"input": norm,
"tools": tools or None,
"stream": True,
},
]
try:
for payload in stream_variants:
@ -205,15 +248,48 @@ class OpenAIResponsesModel(ChatModel):
on_token(text)
return LLMResponse(content=text, tool_calls=tool_calls)
except Exception as exc:
stream_errors.append(str(exc))
emsg = str(exc)
# Fallback: provider thinking-mode replay contract.
if "reasoning_content" in emsg and "thinking mode" in emsg and "must be passed back" in emsg:
try:
forced = dict(payload)
eb = forced.get("extra_body") if isinstance(forced.get("extra_body"), dict) else {}
eb = dict(eb)
eb["thinking"] = {"type": "disabled"}
forced["extra_body"] = eb
forced.pop("reasoning_effort", None)
stream = self._client.responses.create(**forced)
text, tool_calls, final_resp = parse_openai_responses_stream_events(stream, on_token=on_token)
if (not text.strip()) and final_resp:
ot = final_resp.get("output_text")
if isinstance(ot, str) and ot.strip():
text = ot
if on_token:
on_token(text)
return LLMResponse(content=text, tool_calls=tool_calls)
except Exception:
pass
stream_errors.append(emsg)
continue
raise RuntimeError("; ".join(stream_errors) or "responses_stream_all_variants_failed")
except Exception as exc:
logger.info("responses stream failed; fallback to non-stream (%s)", exc)
nonstream_errors: list[str] = []
for payload in (
{"model": self.model, "input": {"messages": norm}, "tools": tools or None},
{"model": self.model, "input": norm, "tools": tools or None},
{
**thinking,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"input": {"messages": norm},
"tools": tools or None,
},
{
**thinking,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"input": norm,
"tools": tools or None,
},
):
try:
resp = self._client.responses.create(**payload)
@ -224,7 +300,25 @@ class OpenAIResponsesModel(ChatModel):
on_token(text)
return LLMResponse(content=text, tool_calls=tool_calls)
except Exception as e2:
nonstream_errors.append(str(e2))
emsg2 = str(e2)
if "reasoning_content" in emsg2 and "thinking mode" in emsg2 and "must be passed back" in emsg2:
try:
forced = dict(payload)
eb = forced.get("extra_body") if isinstance(forced.get("extra_body"), dict) else {}
eb = dict(eb)
eb["thinking"] = {"type": "disabled"}
forced["extra_body"] = eb
forced.pop("reasoning_effort", None)
resp = self._client.responses.create(**forced)
d = _as_dict(resp) or {}
text = str(d.get("output_text") or "")
tool_calls = _collect_tool_calls_from_response_dict(d)
if on_token and text:
on_token(text)
return LLMResponse(content=text, tool_calls=tool_calls)
except Exception:
pass
nonstream_errors.append(emsg2)
continue
raise RuntimeError(
"openai_responses_request_failed: "

View file

@ -912,6 +912,10 @@ class SqliteStore:
conn.execute("ALTER TABLE llm_profile ADD COLUMN hide_in_ui INTEGER NOT NULL DEFAULT 0")
if "owner_user_id" not in prof_cols:
conn.execute("ALTER TABLE llm_profile ADD COLUMN owner_user_id TEXT")
if "thinking_mode_enabled" not in prof_cols:
conn.execute("ALTER TABLE llm_profile ADD COLUMN thinking_mode_enabled INTEGER NOT NULL DEFAULT 0")
if "reasoning_effort" not in prof_cols:
conn.execute("ALTER TABLE llm_profile ADD COLUMN reasoning_effort TEXT")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS llm_profile_user_grant (
@ -2349,8 +2353,8 @@ class SqliteStore:
conn.execute(
"""
INSERT INTO llm_profile
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id)
VALUES (?, ?, ?, ?, ?, NULL, ?, 0, 0, ?)
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id, thinking_mode_enabled, reasoning_effort)
VALUES (?, ?, ?, ?, ?, NULL, ?, 0, 0, ?, 0, '')
""",
(profile_id, name, mode, model, base_url, ts, own),
)
@ -2393,7 +2397,9 @@ class SqliteStore:
SELECT id, name, mode, model, base_url, api_key, updated_at,
COALESCE(is_builtin, 0) AS is_builtin,
COALESCE(hide_in_ui, 0) AS hide_in_ui,
owner_user_id
owner_user_id,
COALESCE(thinking_mode_enabled, 0) AS thinking_mode_enabled,
COALESCE(reasoning_effort, '') AS reasoning_effort
FROM llm_profile
{where_sql}
ORDER BY COALESCE(is_builtin, 0) DESC, COALESCE(hide_in_ui, 0) ASC, updated_at DESC
@ -2465,6 +2471,8 @@ class SqliteStore:
"is_builtin": is_builtin,
"hide_in_ui": bool(int(r["hide_in_ui"] or 0)),
"owner_user_id": own,
"thinking_mode_enabled": bool(int(r["thinking_mode_enabled"] or 0)),
"reasoning_effort": str(r["reasoning_effort"] or "").strip().lower(),
"mutable": mutable,
"visibility_reason": vis,
}
@ -2478,7 +2486,9 @@ class SqliteStore:
SELECT id, name, mode, model, base_url, api_key, updated_at,
COALESCE(is_builtin, 0) AS is_builtin,
COALESCE(hide_in_ui, 0) AS hide_in_ui,
owner_user_id
owner_user_id,
COALESCE(thinking_mode_enabled, 0) AS thinking_mode_enabled,
COALESCE(reasoning_effort, '') AS reasoning_effort
FROM llm_profile
WHERE id = ?
""",
@ -2497,6 +2507,8 @@ class SqliteStore:
"is_builtin": bool(int(r["is_builtin"] or 0)),
"hide_in_ui": bool(int(r["hide_in_ui"] or 0)),
"owner_user_id": str(r["owner_user_id"] or "").strip() if r["owner_user_id"] is not None else "",
"thinking_mode_enabled": bool(int(r["thinking_mode_enabled"] or 0)),
"reasoning_effort": str(r["reasoning_effort"] or "").strip().lower(),
}
def update_llm_profile(
@ -2506,16 +2518,26 @@ class SqliteStore:
mode: str,
model: str | None,
base_url: str | None,
*,
thinking_mode_enabled: bool | None = None,
reasoning_effort: str | None = None,
) -> None:
ts = utc_now_iso()
think_val = None if thinking_mode_enabled is None else (1 if bool(thinking_mode_enabled) else 0)
eff = None if reasoning_effort is None else str(reasoning_effort or "").strip().lower()
if eff is not None and eff not in ("", "low", "medium", "high"):
eff = ""
with self._connect() as conn:
conn.execute(
"""
UPDATE llm_profile
SET name = ?, mode = ?, model = ?, base_url = ?, updated_at = ?
SET name = ?, mode = ?, model = ?, base_url = ?,
thinking_mode_enabled = COALESCE(?, thinking_mode_enabled),
reasoning_effort = COALESCE(?, reasoning_effort),
updated_at = ?
WHERE id = ?
""",
(name, mode, model, base_url, ts, profile_id),
(name, mode, model, base_url, think_val, eff, ts, profile_id),
)
def delete_llm_profile(self, profile_id: str) -> None: