refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH

- Rename platform/ to svc/ to avoid shadowing stdlib platform.
- Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths.
- tests/conftest: prepend repo root to sys.path (no parent-folder package name).
- CI: paths and offline_eval script under repo root.
- Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup).
- Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-13 14:51:17 +08:00
parent 7941b27f58
commit 420abac9f1
405 changed files with 1316 additions and 1345 deletions

View file

@ -0,0 +1 @@
# svc.llm.transports package

View file

@ -0,0 +1,204 @@
from __future__ import annotations
import json
import logging
import os
import uuid
from typing import Any, Optional
from collections.abc import Callable, Iterable
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
logger = logging.getLogger(__name__)
def _as_dict(obj: Any) -> dict[str, Any] | None:
if obj is None:
return None
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
try:
d = obj.model_dump(mode="python")
return d if isinstance(d, dict) else None
except Exception:
return None
return None
def parse_anthropic_stream_events(
events: Iterable[Any],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> tuple[str, list[LLMToolCall]]:
parts: list[str] = []
tool_by_id: dict[str, dict[str, Any]] = {}
current_block: dict[str, Any] | None = None
for ev in events:
d = _as_dict(ev) or {}
typ = str(d.get("type") or "")
if typ in ("message_start", "message_delta", "message_stop"):
continue
if typ == "content_block_start":
cb = d.get("content_block") if isinstance(d.get("content_block"), dict) else {}
current_block = cb
# tool_use blocks carry name/id/input in start
if str(cb.get("type") or "") == "tool_use":
tid = str(cb.get("id") or "") or f"call_{uuid.uuid4().hex}"
tool_by_id[tid] = {
"id": tid,
"name": str(cb.get("name") or ""),
"input_json": cb.get("input") if isinstance(cb.get("input"), dict) else {},
"_input_buf": "",
}
continue
if typ == "content_block_delta":
delta = d.get("delta") if isinstance(d.get("delta"), dict) else {}
dt = str(delta.get("type") or "")
if dt == "text_delta":
t = str(delta.get("text") or "")
if t:
parts.append(t)
if on_token:
on_token(t)
continue
if dt in ("input_json_delta", "tool_use_delta"):
partial = delta.get("partial_json") or delta.get("partial") or ""
s = str(partial or "")
# attach to last tool_use block if any
# Anthropics uses content block index; we just buffer by most recent tool_use id.
if current_block and str(current_block.get("type") or "") == "tool_use":
tid = str(current_block.get("id") or "")
if tid and tid in tool_by_id:
tool_by_id[tid]["_input_buf"] = str(tool_by_id[tid].get("_input_buf") or "") + s
continue
continue
if typ == "content_block_stop":
current_block = None
continue
tool_calls: list[LLMToolCall] = []
for tid, slot in tool_by_id.items():
args = slot.get("input_json") if isinstance(slot.get("input_json"), dict) else {}
buf = str(slot.get("_input_buf") or "").strip()
if buf:
try:
parsed = json.loads(buf)
if isinstance(parsed, dict):
args = parsed
else:
args = {"_raw": parsed}
except Exception:
# keep best-effort
pass
tool_calls.append(LLMToolCall(id=tid, name=str(slot.get("name") or ""), arguments=args, thought_signature=None))
return "".join(parts), tool_calls
class AnthropicMessagesModel(ChatModel):
def __init__(self, *, model: str | None = None, api_key: str | None = None, base_url: str | None = None):
self.model = (model or os.getenv("ANTHROPIC_MODEL") or "claude-3-5-sonnet-latest").strip()
self.api_key = (api_key or os.getenv("ANTHROPIC_API_KEY") or "").strip()
self.base_url = (base_url or os.getenv("ANTHROPIC_BASE_URL") or "").strip() or None
if not self.api_key:
raise RuntimeError("未设置 ANTHROPIC_API_KEY,无法使用 Anthropic 模型")
try:
import anthropic
except Exception as exc:
raise RuntimeError("未安装 anthropic 依赖,请先 pip install -r requirements.txt") from exc
kw: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
kw["base_url"] = self.base_url
self._client = anthropic.Anthropic(**kw)
@staticmethod
def _to_anthropic_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_chunks: list[str] = []
out: list[dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
content = m.get("content")
if role == "system":
if isinstance(content, str) and content.strip():
system_chunks.append(content)
continue
if role in ("user", "assistant"):
if isinstance(content, str):
out.append({"role": role, "content": content})
else:
out.append({"role": role, "content": str(content)})
continue
if role == "tool":
# Anthropic expects tool results as user content blocks; we stringify.
name = str(m.get("name") or m.get("tool_name") or m.get("toolName") or "tool").strip()
out.append({"role": "user", "content": f"[tool_result:{name}] {str(content)}"})
continue
return "\n\n".join(system_chunks).strip(), out
@staticmethod
def _to_anthropic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for t in tools or []:
if not isinstance(t, dict) or str(t.get("type") or "") != "function":
continue
fn = t.get("function")
if not isinstance(fn, dict):
continue
name = str(fn.get("name") or "").strip()
if not name:
continue
out.append(
{
"name": name,
"description": str(fn.get("description") or ""),
"input_schema": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object"},
}
)
return out
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
system, msgs = self._to_anthropic_messages(messages)
atools = self._to_anthropic_tools(tools)
try:
stream = self._client.messages.stream(
model=self.model,
system=system or None,
messages=msgs,
tools=atools or None,
max_tokens=2048,
)
text, tool_calls = parse_anthropic_stream_events(stream, on_token=on_token)
return LLMResponse(content=text, tool_calls=tool_calls)
except Exception as exc:
logger.info("anthropic stream failed; fallback to non-stream (%s)", exc)
resp = self._client.messages.create(
model=self.model,
system=system or None,
messages=msgs,
tools=atools or None,
max_tokens=2048,
)
d = _as_dict(resp) or {}
content = d.get("content")
text = ""
if isinstance(content, list):
for blk in content:
if isinstance(blk, dict) and blk.get("type") == "text":
text += str(blk.get("text") or "")
if on_token and text:
on_token(text)
return LLMResponse(content=text, tool_calls=[])
__all__ = ["AnthropicMessagesModel", "parse_anthropic_stream_events"]

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any, Optional
from collections.abc import Callable
@dataclass(frozen=True)
class LLMToolCall:
id: str
name: str
arguments: dict[str, Any]
# Gemini OpenAI-compat and native transports may require preserving a signature across tool loops.
thought_signature: str | None = None
@dataclass(frozen=True)
class LLMResponse:
content: str
tool_calls: list[LLMToolCall]
reasoning_content: str = ""
class ChatModel:
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
raise NotImplementedError
def coerce_thought_signature_for_storage(v: Any) -> str | None:
if v is None:
return None
if isinstance(v, str):
return v
if isinstance(v, (bytes, bytearray)):
try:
return bytes(v).decode("utf-8")
except UnicodeDecodeError:
return base64.b64encode(bytes(v)).decode("ascii")
if isinstance(v, (dict, list)):
return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
return str(v)
def normalize_image_b64_payload(raw: Any) -> str:
if raw is None:
return ""
if isinstance(raw, (bytes, bytearray)):
return base64.b64encode(bytes(raw)).decode("ascii")
s = str(raw).strip()
if not s:
return ""
if s.startswith("data:") and ";base64," in s:
s = s.split(";base64,", 1)[-1].strip()
return "".join(s.split())

View file

@ -0,0 +1,267 @@
from __future__ import annotations
import json
import os
import uuid
from typing import Any, Optional
from collections.abc import Callable
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
class GoogleGeminiChatModel(ChatModel):
"""Native Google Gemini streaming transport (SSE), modeled after Oclaw's google transport."""
def __init__(self, *, model: str, api_key: str, base_url: str | None = None):
self.model = (model or "").strip() or "gemini-2.5-pro"
self.api_key = (api_key or "").strip()
self.base_url = (base_url or "").strip() or "https://generativelanguage.googleapis.com/v1beta"
if not self.api_key:
raise RuntimeError("未设置 GOOGLE_API_KEY,无法使用 Gemini 原生接口")
def _tool_decls_from_openai_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
decls: list[dict[str, Any]] = []
for t in tools or []:
if not isinstance(t, dict) or str(t.get("type") or "") != "function":
continue
fn = t.get("function")
if not isinstance(fn, dict):
continue
name = str(fn.get("name") or "").strip()
if not name:
continue
decls.append(
{
"name": name,
"description": str(fn.get("description") or ""),
# Oclaw uses `parametersJsonSchema`
"parametersJsonSchema": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object"},
}
)
return decls
@staticmethod
def _thinking_config_from_env() -> dict[str, Any] | None:
enabled = str(os.getenv("AIA_GEMINI_THINKING") or "").strip().lower()
if enabled in ("0", "false", "no", "off"):
return {"includeThoughts": False}
if enabled in ("1", "true", "yes", "on"):
cfg: dict[str, Any] = {"includeThoughts": True}
lvl = str(os.getenv("AIA_GEMINI_THINKING_LEVEL") or "").strip()
if lvl:
cfg["thinkingLevel"] = lvl
bud = str(os.getenv("AIA_GEMINI_THINKING_BUDGET") or "").strip()
if bud.isdigit():
cfg["thinkingBudget"] = int(bud)
return cfg
return None
@staticmethod
def _parse_tool_result_payload(raw: Any) -> Any:
if raw is None:
return {}
if isinstance(raw, (dict, list)):
return raw
s = str(raw or "").strip()
if not s:
return {}
try:
return json.loads(s)
except Exception:
return {"raw": s}
@staticmethod
def _openai_messages_to_gemini_contents(messages: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
system_parts: list[dict[str, Any]] = []
contents: list[dict[str, Any]] = []
def _append(role: str, parts: list[dict[str, Any]]) -> None:
if not parts:
return
contents.append({"role": role, "parts": parts})
for m in messages or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
content = m.get("content")
if role == "system":
if isinstance(content, str) and content.strip():
system_parts.append({"text": content})
continue
if role == "user":
parts: list[dict[str, Any]] = []
if isinstance(content, str):
if content.strip():
parts.append({"text": content})
elif isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and str(item.get("text") or "").strip():
parts.append({"text": str(item.get("text") or "")})
_append("user", parts)
continue
if role == "assistant":
parts: list[dict[str, Any]] = []
if isinstance(content, str) and content.strip():
parts.append({"text": content})
_append("model", parts)
continue
if role == "tool":
name = str(m.get("name") or "").strip() or str(m.get("tool_name") or m.get("toolName") or "").strip()
payload = GoogleGeminiChatModel._parse_tool_result_payload(content)
_append("user", [{"functionResponse": {"name": name or "tool", "response": payload}}])
continue
system_instruction = {"parts": system_parts} if system_parts else None
return system_instruction, contents
def _build_url(self) -> str:
base = self._normalize_base_url_for_native_sse(self.base_url).rstrip("/")
model_path = self.model
if not model_path.startswith("models/") and not model_path.startswith("tunedModels/"):
model_path = f"models/{model_path}"
# Prefer header auth (Oclaw-style); avoid forcing key into query string.
return f"{base}/{model_path}:streamGenerateContent?alt=sse"
@staticmethod
def _normalize_base_url_for_native_sse(base_url: str) -> str:
"""Normalize unified gateway base_url for Gemini native SSE.
Many unified gateways expose OpenAI-compat under `/v1/*`, but Gemini native endpoints
are usually implemented under `/v1beta/*` semantics.
"""
raw = str(base_url or "").strip()
if not raw:
return raw
# Strip trailing slashes first.
while raw.endswith("/"):
raw = raw[:-1]
# Common unified gateway layout: https://host/v1 -> https://host/v1beta
if raw.lower().endswith("/v1"):
return raw[:-3] + "/v1beta"
# Some gateways keep an `/openai` suffix for compat endpoints.
if raw.lower().endswith("/openai"):
raw = raw[:-7]
if raw.lower().endswith("/v1"):
return raw[:-3] + "/v1beta"
return raw
return raw
def _auth_headers(self) -> dict[str, str]:
k = str(self.api_key or "").strip()
if not k:
return {}
# If user stored a bearer token in the profile secret.
if k.lower().startswith("bearer "):
return {"authorization": k}
# Gemini API supports x-goog-api-key; unified gateways often accept this too.
return {"x-goog-api-key": k}
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
system_instruction, contents = self._openai_messages_to_gemini_contents(messages)
decls = self._tool_decls_from_openai_tools(tools)
req: dict[str, Any] = {"contents": contents}
if system_instruction is not None:
req["systemInstruction"] = system_instruction
if decls:
req["tools"] = [{"functionDeclarations": decls}]
thinking_cfg = self._thinking_config_from_env()
if thinking_cfg is not None:
req["generationConfig"] = {"thinkingConfig": thinking_cfg}
url = self._build_url()
try:
import httpx
except Exception as exc:
raise RuntimeError("missing dependency: httpx") from exc
thinking_parts: list[str] = []
thinking_signature: str | None = None
out_text: list[str] = []
tool_calls: list[LLMToolCall] = []
def _stream_once(stream_url: str) -> None:
with httpx.Client(timeout=httpx.Timeout(60.0, connect=10.0)) as client:
with client.stream(
"POST",
stream_url,
json=req,
headers={
"Content-Type": "application/json",
"accept": "text/event-stream",
**self._auth_headers(),
},
) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line or not str(line).startswith("data:"):
continue
data = str(line)[len("data:") :].strip()
if not data or data == "[DONE]":
continue
try:
chunk = json.loads(data)
except Exception:
continue
cands = chunk.get("candidates") if isinstance(chunk, dict) else None
if not isinstance(cands, list) or not cands:
continue
c0 = cands[0] if isinstance(cands[0], dict) else {}
content0 = c0.get("content") if isinstance(c0.get("content"), dict) else {}
parts = content0.get("parts") if isinstance(content0.get("parts"), list) else []
for p in parts:
if not isinstance(p, dict):
continue
if p.get("thought") is True:
txt = str(p.get("text") or "")
if txt:
thinking_parts.append(txt)
if on_token:
on_token(txt)
sig = p.get("thoughtSignature")
if isinstance(sig, str) and sig:
thinking_signature = sig
continue
fc = p.get("functionCall") if isinstance(p.get("functionCall"), dict) else None
if fc is not None:
name = str(fc.get("name") or "").strip()
args = fc.get("args") if isinstance(fc.get("args"), dict) else {}
tid = str(fc.get("id") or "") or f"call_{uuid.uuid4().hex}"
tool_calls.append(
LLMToolCall(id=tid, name=name, arguments=args, thought_signature=thinking_signature)
)
continue
txt = str(p.get("text") or "")
if txt:
out_text.append(txt)
if on_token:
on_token(txt)
try:
_stream_once(url)
except Exception as exc:
# If gateway returns 500 on /v1* SSE path, try /v1beta* fallback once.
msg = str(exc or "").lower()
if "500" in msg and "/v1/" in str(url).lower():
alt = str(url).replace("/v1/", "/v1beta/")
_stream_once(alt)
else:
raise
return LLMResponse(
content="".join(out_text),
tool_calls=tool_calls,
reasoning_content="".join(thinking_parts).strip(),
)
__all__ = ["GoogleGeminiChatModel"]

View file

@ -0,0 +1,728 @@
from __future__ import annotations
import json
import logging
import os
import re
import uuid
from typing import Any, Optional
from collections.abc import Callable
from svc.llm.tool_schema import complete_openai_tools_wire_parameters
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload, coerce_thought_signature_for_storage
logger = logging.getLogger(__name__)
def _likely_gemini_openai_compat_base_url(url: str | None) -> bool:
if not url:
return False
u = url.lower()
return "generativelanguage.googleapis.com" in u or "aiplatform.googleapis.com" in u
def _model_id_suggests_gemini(model: str | None) -> bool:
return "gemini" in (model or "").lower()
def _is_minimax_compat(model: str | None, base_url: str | None) -> bool:
m = str(model or "").strip().lower()
b = str(base_url or "").strip().lower()
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 _should_force_text_only_messages(base_url: str | None) -> bool:
del base_url
# Opt-in only. Avoid hard-coding vendor/domain specific heuristics.
return _truthy_env("AIA_OPENAI_FORCE_TEXT_CONTENT", "0")
def _multimodal_proactive_downgrade_enabled() -> bool:
raw = str(os.getenv("AIA_OPENAI_MULTIMODAL_PROACTIVE_DOWNGRADE") or "1").strip().lower()
return raw not in {"0", "false", "no", "off"}
def _text_only_multimodal_substrings() -> tuple[str, ...]:
raw = os.getenv("AIA_OPENAI_TEXT_ONLY_MULTIMODAL_SUBSTRINGS")
if raw is None:
return ("deepseek",)
s = str(raw).strip().lower()
if s in {"", "-", "none", "off"}:
return ()
return tuple(part.strip().lower() for part in str(raw).split(",") if part.strip())
def _messages_contain_list_with_image(messages: list[dict[str, Any]]) -> bool:
for m in messages or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if not isinstance(c, list):
continue
for item in c:
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
return True
return False
def _deepseek_strict_tool_mode_enabled(*, base_url: str | None, model: str | None) -> bool:
"""DeepSeek OpenAI-compat: optional ``strict_tool_mode`` in request body (via ``extra_body``).
Upstream documents this as reducing DSML-in-text tool output in favor of native ``tool_calls``.
Opt out with ``AIA_DEEPSEEK_STRICT_TOOL_MODE=0`` when a proxy rejects unknown fields.
"""
raw = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower()
if raw in ("0", "false", "no", "off"):
return False
hay = f"{base_url or ''} {model or ''}".lower()
return "deepseek" in hay
def _merge_deepseek_strict_tool_mode_extra_body(
kwargs: dict[str, Any],
*,
base_url: str | None,
model: str | None,
use_tools: bool,
) -> None:
if not use_tools or not _deepseek_strict_tool_mode_enabled(base_url=base_url, model=model):
return
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["strict_tool_mode"] = True
kwargs["extra_body"] = extra_body
def _should_proactively_downgrade_multimodal_messages(
messages: list[dict[str, Any]],
*,
model: str | None,
base_url: str | None,
) -> bool:
if _should_force_text_only_messages(base_url):
return _messages_contain_list_with_image(messages)
if not _multimodal_proactive_downgrade_enabled():
return False
if not _messages_contain_list_with_image(messages):
return False
parts = _text_only_multimodal_substrings()
if not parts:
return False
hay = f"{model or ''} {base_url or ''}".lower()
return any(p and p in hay for p in parts)
def _wire_error_message(exc: BaseException) -> str:
chunks: list[str] = [str(exc)]
em = getattr(exc, "message", None)
if isinstance(em, str) and em.strip():
chunks.append(em)
body = getattr(exc, "body", None)
if isinstance(body, dict):
try:
chunks.append(json.dumps(body, ensure_ascii=False, default=str))
except Exception:
chunks.append(repr(body))
elif isinstance(body, str) and body.strip():
chunks.append(body)
resp = getattr(exc, "response", None)
if resp is not None:
t = ""
try:
rt = getattr(resp, "text", "")
if isinstance(rt, str):
t = rt
except Exception:
pass
if not t.strip():
try:
ct = getattr(resp, "content", None)
if isinstance(ct, (bytes, bytearray)):
t = bytes(ct).decode("utf-8", errors="replace")
except Exception:
pass
if t.strip():
chunks.append(t)
inner = getattr(exc, "__cause__", None)
if inner is not None:
chunks.append(str(inner))
return "\n".join(chunks)
def _is_text_only_gateway_error(msg: str) -> bool:
m = str(msg or "").strip().lower()
if not m:
return False
if "unknown variant image_url" in m:
return True
if "image_url" in m and "expected text" in m:
return True
if "failed to deserialize" in m and ("expected text" in m or "unknown variant image_url" in m):
return True
if "deserialize" in m and "image_url" in m and ("expected text" in m or "messages[" in m):
return True
if "invalid_request_error" in m and ("image_url" in m or "expected text" in m):
return True
return False
def _flatten_message_content_to_text(content: Any) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content or "")
parts: list[str] = []
for item in content:
if isinstance(item, str):
if item.strip():
parts.append(item)
continue
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("text", "input_text"):
txt = str(item.get("text") or item.get("content") or "").strip()
if txt:
parts.append(txt)
continue
if typ in ("image_url", "input_image"):
# Proactive / forced text-only path without OCR injection (or OCR disabled).
parts.append(
"【图片】本消息含图片,但当前以纯文本发往上游模型,像素未传入;"
"若需要图中细节,请改用支持多模态的模型或开启看图 OCR 降级通道。"
)
continue
return "\n".join([p for p in parts if p]).strip()
def _multimodal_downgrade_ocr_enabled() -> bool:
"""When True, text-only downgrade replaces image blocks with OCR text via AIA_OCR_* lane."""
return str(os.getenv("AIA_MULTIMODAL_DOWNGRADE_OCR") or "1").strip().lower() not in {
"0",
"false",
"no",
"off",
}
def _image_block_to_url(item: dict[str, Any]) -> str | None:
typ = str(item.get("type") or "").strip().lower()
if typ == "image_url":
u = item.get("image_url")
if isinstance(u, dict):
s = str(u.get("url") or "").strip()
return s or None
if isinstance(u, str) and u.strip():
return u.strip()
if typ == "input_image":
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
if b64:
return f"data:{mime};base64,{b64}"
return None
def _message_list_contains_image_block(content: Any) -> bool:
if not isinstance(content, list):
return False
for item in content:
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
return True
return False
def _downgrade_ocr_wrap_for_llm(ocr_text: str) -> str:
"""User-facing copy injected into the main model when multimodal was OCR-downgraded to text."""
head = (
"【图片内容·OCR】当前主对话模型仅接收纯文本,无法直接读图;"
"下面是由独立看图通道从用户图片中转写提取的正文。请把它和用户的文字问题一起当作依据来回答;"
"转写可能有漏字、错行或与截图不完全一致,重要结论可请用户核对原图或补充说明。"
)
return f"{head}\n\n{ocr_text.strip()}"
def _flatten_message_content_for_text_gateway(content: Any) -> str:
"""Flatten list content to a string; image blocks may become OCR text when configured."""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content or "")
if _multimodal_downgrade_ocr_enabled() and _message_list_contains_image_block(content):
try:
from svc.llm.image_ocr_client import (
VISION_OCR_EXTRACT_PROMPT_ZH,
send_ocr_image_messages,
vision_llm_backend_status,
)
except Exception:
return _flatten_message_content_to_text(content)
if vision_llm_backend_status().get("ok"):
parts: list[str] = []
for item in content:
if isinstance(item, str):
if item.strip():
parts.append(item)
continue
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
url = _image_block_to_url(item)
if not url:
parts.append(
"【图片】无法从本消息中还原图片数据,图中细节未传入本轮模型;"
"若作答需要像素级信息,请提示用户重发或改用支持多模态的模型配置。"
)
continue
try:
out = send_ocr_image_messages(images=[url], prompt=VISION_OCR_EXTRACT_PROMPT_ZH)
except Exception as exc:
parts.append(
f"【图片】独立看图通道 OCR 调用异常({type(exc).__name__}),"
"本张图的正文未能写入对话;可请用户改述图中要点或稍后重试。"
)
continue
if not out.get("ok"):
err = str(out.get("error") or "unknown")
parts.append(
f"【图片】看图通道返回失败({err}),本张图未转成文字;"
"请据用户文字说明作答,或提示检查 OCR 配置/重发图片。"
)
continue
t = str(out.get("text") or "").strip()
if not t:
parts.append(
"【图片】看图通道未返回有效文字(可能模型拒识或图不清晰)。"
"请提示用户补充文字说明或重发更清晰的截图。"
)
else:
parts.append(_downgrade_ocr_wrap_for_llm(t))
continue
if typ in ("text", "input_text"):
txt = str(item.get("text") or item.get("content") or "").strip()
if txt:
parts.append(txt)
continue
return "\n\n".join([p for p in parts if p]).strip()
return _flatten_message_content_to_text(content)
def _normalize_messages_for_text_only_gateway(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict):
continue
mm = dict(m)
if isinstance(mm.get("content"), list):
mm["content"] = _flatten_message_content_for_text_gateway(mm.get("content"))
out.append(mm)
return out
def _find_thought_signature_in_obj(o: Any) -> str | None:
if isinstance(o, dict):
for k in ("thought_signature", "thoughtSignature"):
v = o.get(k)
if v is not None:
coerced = coerce_thought_signature_for_storage(v)
if coerced is not None:
return coerced
for v in o.values():
found = _find_thought_signature_in_obj(v)
if found is not None:
return found
elif isinstance(o, list):
for v in o:
found = _find_thought_signature_in_obj(v)
if found is not None:
return found
return None
def _extract_thought_signature_from_message_tool_call(tc: Any) -> str | None:
if hasattr(tc, "model_dump"):
try:
d = tc.model_dump(mode="python")
ec = d.get("extra_content")
if isinstance(ec, dict):
g = ec.get("google")
if isinstance(g, dict) and "thought_signature" in g:
return coerce_thought_signature_for_storage(g.get("thought_signature"))
except Exception:
pass
ec = getattr(tc, "extra_content", None)
if isinstance(ec, dict):
g = ec.get("google")
if isinstance(g, dict) and "thought_signature" in g:
return coerce_thought_signature_for_storage(g.get("thought_signature"))
return _extract_thought_signature_from_tool_delta(tc)
def _extract_thought_signature_from_tool_delta(tc: Any) -> str | None:
for attr in ("thought_signature", "thoughtSignature"):
v = getattr(tc, attr, None)
if v is not None:
return coerce_thought_signature_for_storage(v)
extra = getattr(tc, "model_extra", None)
if isinstance(extra, dict):
for k in ("thought_signature", "thoughtSignature"):
ev = extra.get(k)
if ev is not None:
return coerce_thought_signature_for_storage(ev)
pyd_ex = getattr(tc, "__pydantic_extra__", None)
if isinstance(pyd_ex, dict):
for k in ("thought_signature", "thoughtSignature"):
ev = pyd_ex.get(k)
if ev is not None:
return coerce_thought_signature_for_storage(ev)
if hasattr(tc, "model_dump"):
try:
found = _find_thought_signature_in_obj(tc.model_dump(mode="python"))
if found is not None:
return found
except Exception:
pass
return None
class OpenAIChatModel(ChatModel):
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"
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 模型")
try:
from openai import OpenAI
except Exception as e:
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = OpenAI(**client_kwargs)
self._skip_tools: bool = False
def _create_chat_completion(self, norm_msgs: list[dict[str, Any]], tools: list[dict[str, Any]], *, stream: bool):
# replay_policy/tool_wire_policy live in sibling modules; import lazily to avoid cycles.
try:
from svc.llm.replay_policy import apply_replay_policy_to_messages, resolve_replay_policy
policy = resolve_replay_policy(self.base_url, self.model)
if policy.enabled:
norm_msgs = apply_replay_policy_to_messages(norm_msgs, policy)
except Exception as exc:
logger.warning("replay_policy apply failed (%s); continuing without rewrite", exc)
minimax_compat = _is_minimax_compat(self.model, self.base_url)
use_tools = bool(tools)
cleaned_msgs: list[dict[str, Any]] = []
for m in norm_msgs or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
if minimax_compat and role == "assistant" and isinstance(m.get("tool_calls"), list):
# MiniMax OpenAI-compat can reject long-history tool_use/tool_result replays.
# Keep text semantics, but remove historical wire-level tool_calls.
mm = dict(m)
mm.pop("tool_calls", None)
cleaned_msgs.append(mm)
continue
if minimax_compat and role == "tool":
# Downgrade history tool_result rows to plain assistant text context,
# avoiding strict tool_result sequence validation on replay.
tcid = str(m.get("tool_call_id") or m.get("call_id") or "").strip()
tname = str(m.get("name") or "").strip()
raw = str(m.get("content") or "")
prefix = "[tool_result replay]"
if tname:
prefix += f" name={tname}"
if tcid:
prefix += f" id={tcid}"
cleaned_msgs.append({"role": "assistant", "content": f"{prefix}\n{raw}".strip()})
continue
if role == "tool":
mm = dict(m)
for k in ("tool_call_id", "call_id"):
if k in mm:
v = mm.get(k)
try:
sv = str(v).strip() if v is not None else ""
except Exception:
sv = ""
if not sv:
mm.pop(k, None)
else:
mm[k] = sv
cleaned_msgs.append(mm)
else:
cleaned_msgs.append(m)
msgs_wire = cleaned_msgs
if _should_force_text_only_messages(self.base_url) or _should_proactively_downgrade_multimodal_messages(
cleaned_msgs,
model=self.model,
base_url=self.base_url,
):
msgs_wire = _normalize_messages_for_text_only_gateway(cleaned_msgs)
kwargs: dict[str, Any] = {"model": self.model, "messages": msgs_wire, "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 svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from runtime.tools.exposure_plan import build_llm_tools_plan
plan = build_llm_tools_plan(
store=SqliteStore(db_path()),
role="",
base_url=self.base_url,
max_json_bytes=_default_max_openai_tools_json_bytes(self.base_url),
include_mcp=False,
preview_internal=False,
raw_openai_tools_override=tools,
)
kwargs["tools"] = plan.tools_wired
except Exception:
kwargs["tools"] = complete_openai_tools_wire_parameters(tools)
_merge_deepseek_strict_tool_mode_extra_body(
kwargs, base_url=self.base_url, model=self.model, use_tools=use_tools
)
try:
return self._client.chat.completions.create(**kwargs)
except Exception as exc:
msg = _wire_error_message(exc)
if _is_text_only_gateway_error(msg) and isinstance(kwargs.get("messages"), list):
kwargs["messages"] = _normalize_messages_for_text_only_gateway(kwargs["messages"])
return self._client.chat.completions.create(**kwargs)
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
reasoning_parts = getattr(msg, "reasoning_content", None) or ""
reasoning_text = str(reasoning_parts).strip() if reasoning_parts else ""
content = msg.content or ""
if on_token:
if reasoning_text:
on_token(reasoning_text)
if content:
on_token(content)
tool_calls: list[LLMToolCall] = []
if msg.tool_calls:
for tc in msg.tool_calls:
tid = str(getattr(tc, "id", "") or "")
fn = getattr(tc, "function", None)
name = str(fn.name) if fn and getattr(fn, "name", None) else ""
args_raw = str(fn.arguments) if fn and getattr(fn, "arguments", None) is not None else "{}"
if not name and not tid:
continue
if not tid:
tid = f"call_{uuid.uuid4().hex}"
try:
args = json.loads(args_raw)
except json.JSONDecodeError:
args = {"_raw": args_raw}
sig = _extract_thought_signature_from_message_tool_call(tc)
if sig is None and fn is not None:
sig = _extract_thought_signature_from_tool_delta(fn)
tool_calls.append(LLMToolCall(id=tid, name=name, arguments=args, thought_signature=sig))
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
# Normalize input_image to image_url.
norm_msgs = []
for m in messages:
if m.get("role") == "user" and isinstance(m.get("content"), list):
norm_content = []
for item in m["content"]:
if isinstance(item, dict) and item.get("type") == "input_image":
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
if not b64:
continue
norm_content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
else:
norm_content.append(item)
norm_msgs.append({"role": m["role"], "content": norm_content})
else:
norm_msgs.append(m)
try:
stream = self._create_chat_completion(norm_msgs, tools, stream=True)
except Exception as exc:
logger.info("Streaming chat completion failed; fallback to non-stream (%s)", exc)
completion = self._create_chat_completion(norm_msgs, tools, stream=False)
return self._llm_response_from_completion(completion, on_token=on_token)
content_parts: list[str] = []
tool_acc: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
def _consume_stream(stream_obj: Any) -> None:
nonlocal content_parts, tool_acc, reasoning_parts
for chunk in stream_obj:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None) or ""
if rc:
reasoning_parts.append(rc)
if on_token:
on_token(rc)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = int(tc.index)
slot = tool_acc.setdefault(idx, {"id": None, "name": None, "arguments": "", "thought_signature": None})
if tc.id:
slot["id"] = tc.id
if tc.function:
if tc.function.name:
slot["name"] = tc.function.name
if tc.function.arguments:
slot["arguments"] = f"{slot['arguments']}{tc.function.arguments}"
sig = _extract_thought_signature_from_message_tool_call(tc)
if sig is None:
sig = _extract_thought_signature_from_tool_delta(tc)
if sig is None and tc.function is not None:
sig = _extract_thought_signature_from_tool_delta(tc.function)
if sig is not None:
slot["thought_signature"] = sig
if delta.content:
content_parts.append(delta.content)
if on_token:
on_token(delta.content)
try:
_consume_stream(stream)
except Exception:
raise
content = "".join(content_parts)
reasoning_text = "".join(reasoning_parts).strip()
tool_calls: list[LLMToolCall] = []
for idx in sorted(tool_acc.keys()):
slot = tool_acc[idx]
tid = slot.get("id") or ""
name = slot.get("name") or ""
args_raw = slot.get("arguments") or "{}"
if not name and not tid:
continue
if not tid:
tid = f"call_{uuid.uuid4().hex}"
try:
args = json.loads(args_raw)
except json.JSONDecodeError:
args = {"_raw": args_raw}
ts_raw = slot.get("thought_signature")
tsig = coerce_thought_signature_for_storage(ts_raw) if ts_raw is not None else None
tool_calls.append(LLMToolCall(id=str(tid), name=str(name), arguments=args, thought_signature=tsig))
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
__all__ = [
"OpenAIChatModel",
"_likely_gemini_openai_compat_base_url",
"_model_id_suggests_gemini",
"_deepseek_strict_tool_mode_enabled",
"_merge_deepseek_strict_tool_mode_extra_body",
]
def _default_max_openai_tools_json_bytes(base_url: str | None) -> int | None:
u = str(base_url or "").strip().lower()
if not u:
return None
# Conservative caps for some OpenAI-compatible gateways.
if "dashscope" in u or "aliyuncs" in u:
return 28_000
return None

View file

@ -0,0 +1,811 @@
from __future__ import annotations
import json
import logging
import os
import sys
import uuid
from typing import Any, Optional
from collections.abc import Callable, Iterable
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload
logger = logging.getLogger(__name__)
def _redact_nested_json_preview(obj: Any, *, max_chars: int = 4000) -> str:
"""Best-effort JSON preview for logs (truncate; redact mega data URLs)."""
try:
s = json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
s = str(obj)
def _clip_data_urls(t: str) -> str:
out_parts: list[str] = []
i = 0
needle = "data:"
while i < len(t):
j = t.find(needle, i)
if j < 0:
out_parts.append(t[i:])
break
out_parts.append(t[i:j])
k = j + len(needle)
while k < len(t) and t[k] not in "?;, \n\r\t\"]":
k += 1
if k < len(t) and t[k:k + 8] == ";base64,":
end = k + 8
while end < len(t) and t[end] not in "\"}] \n\r\t":
end += 1
seg = end - (k + 8)
out_parts.append(f"data:<redacted base64 ~{seg} chars>")
i = end
continue
out_parts.append(t[j:k])
i = k
return "".join(out_parts)
s = _clip_data_urls(s)
if len(s) > max_chars:
return s[: max_chars - 40] + "\n...<<truncated>>\n..."
return s
def _deep_redact_for_debug(obj: Any) -> Any:
"""Recursive copy for logs: shorten giant ``data:...;base64,...`` and very long strings."""
if isinstance(obj, dict):
return {str(k): _deep_redact_for_debug(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_deep_redact_for_debug(x) for x in obj]
if isinstance(obj, str):
s = obj
if ";base64," in s and s.strip().startswith("data:") and len(s) > 160:
head, _, _tail = s.partition(";base64,")
return f"{head};base64,<redacted ~{len(s) - len(head) - 8} b64 chars>"
if len(s) > 16_000:
return s[:12_000] + f"...<truncated {len(s) - 12_000} chars>"
return s
return obj
def _safe_int(raw: str | None, default: int, *, max_value: int = 2_000_000) -> int:
try:
value = int(str(raw or "").strip())
except Exception:
return default
if value < 1:
return default
return min(value, max_value)
def _log_openai_responses_wire_kwargs(payload: dict[str, Any], *, variant: str, phase: str) -> None:
"""Temporary diagnostics: log kwargs passed into ``OpenAI.responses.create`` (after our assembly).
Enable: ``AIA_RESPONSES_DEBUG_PRINT_PAYLOAD=1`` — emits ``logging.warning`` **and** the same JSON on **stderr**
(gateway控制台默认可见,不依赖 logging level)。
Notes:
- OpenAI SDK may still apply minor JSON transforms on send; this matches **our** arguments.
- Base64 / long ``data:`` URLs are redacted to keep logs readable.
"""
raw = str(os.getenv("AIA_RESPONSES_DEBUG_PRINT_PAYLOAD") or "").strip().lower()
if raw not in ("1", "true", "yes", "on"):
return
try:
dbg = _deep_redact_for_debug(dict(payload))
txt = json.dumps(dbg, ensure_ascii=False, indent=2, default=str)
cap = _safe_int(os.getenv("AIA_RESPONSES_DEBUG_PRINT_MAX_CHARS"), 120_000, max_value=500_000)
if len(txt) > cap:
txt = txt[: max(cap - 80, 0)] + "\n...<<truncated>>\n"
logger.warning(
"openai_responses DEBUG wire kwargs [%s] variant=%s (%d chars):\n%s",
phase,
variant,
len(txt),
txt,
)
try:
sys.stderr.write(
f"\n[oclaw openai_responses DEBUG] phase={phase} variant={variant} chars={len(txt)}\n{txt}\n\n"
)
sys.stderr.flush()
except Exception:
pass
except Exception as exc:
logger.warning("openai_responses DEBUG payload serialization failed: %s", exc)
def _openai_sdk_diagnostic_text(exc: BaseException) -> str:
"""``str(APIStatusError)`` is often only ``Error code: 400``; validation detail lives in ``body['message']``."""
chunks: list[str] = [str(exc)]
msg = getattr(exc, "message", None)
if isinstance(msg, str) and msg.strip() and msg not in chunks:
chunks.append(msg)
bod = getattr(exc, "body", None)
if isinstance(bod, dict):
bm = bod.get("message")
if isinstance(bm, str) and bm.strip():
chunks.append(bm)
err = bod.get("error")
if isinstance(err, dict):
em = err.get("message")
if isinstance(em, str) and em.strip():
chunks.append(em)
return "\n".join(chunks)
def _is_input_messages_validation_error(exc: BaseException) -> bool:
"""Third-party gateways often return 400/422 with Pydantic paths like ``input.messages.0.role``."""
m = _openai_sdk_diagnostic_text(exc).lower()
if "input.messages" in m:
return True
if "input should be 'user'" in m and "content" in m:
return True
return False
def _as_dict(obj: Any) -> dict[str, Any] | None:
if obj is None:
return None
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
try:
d = obj.model_dump(mode="python")
return d if isinstance(d, dict) else None
except Exception:
return None
return None
def _collect_tool_calls_from_response_dict(resp: dict[str, Any]) -> list[LLMToolCall]:
out: list[LLMToolCall] = []
items = resp.get("output")
if not isinstance(items, list):
return out
for it in items:
d = _as_dict(it)
if not d:
continue
if str(d.get("type") or "") in ("function_call", "tool_call"):
call_id = str(d.get("call_id") or d.get("id") or "") or f"call_{uuid.uuid4().hex}"
name = str(d.get("name") or "") or str((d.get("function") or {}).get("name") or "")
args = d.get("arguments") or d.get("input") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {"_raw": args}
if not isinstance(args, dict):
args = {"_raw": args}
out.append(LLMToolCall(id=call_id, name=name, arguments=args, thought_signature=None))
return out
def _stream_reasoning_delta_text(ev: dict[str, Any]) -> str:
"""Extract a textual chunk from a Responses streaming reasoning-related event."""
delta = ev.get("delta")
if isinstance(delta, dict):
for k in ("text", "delta", "summary", "content"):
v = delta.get(k)
if isinstance(v, str) and v:
return v
return ""
if delta is not None:
s = str(delta)
return s
for k in ("text", "summary", "content"):
v = ev.get(k)
if isinstance(v, str) and v:
return v
return ""
def _reasoning_from_completed_response(resp: dict[str, Any]) -> str:
"""Best-effort reasoning string from a terminal Responses ``response`` object."""
for key in ("reasoning", "reasoning_summary", "reasoning_text", "reasoning_content"):
v = resp.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
items = resp.get("output")
if not isinstance(items, list):
return ""
chunks: list[str] = []
for it in items:
d = _as_dict(it) or {}
typ = str(d.get("type") or "").lower()
if "reasoning" not in typ:
continue
for k in ("summary", "text", "content"):
x = d.get(k)
if isinstance(x, str) and x.strip():
chunks.append(x.strip())
break
return "\n".join(chunks).strip()
def parse_openai_responses_stream_events(
events: Iterable[Any],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> tuple[str, list[LLMToolCall], dict[str, Any] | None, str]:
"""Pure stream parser (offline-testable).
Returns: (assembled_output_text, tool_calls, final_response_dict?, reasoning_text)
Reasoning models emit ``response.reasoning_summary_text.delta`` (and similar) events; those are
forwarded to ``on_token`` for live UI, accumulated into ``reasoning_text`` for persistence, and
are **not** merged into the returned ``assembled_output_text`` (assistant body).
"""
parts: list[str] = []
reasoning_parts: list[str] = []
final_resp: dict[str, Any] | None = None
def _emit_reasoning_chunk(s: str) -> None:
s = str(s or "")
if not s:
return
reasoning_parts.append(s)
if on_token:
on_token(s)
for ev in events:
d = _as_dict(ev) or {}
typ = str(d.get("type") or "")
# Reasoning / thinking summary stream (do not append to output_text body).
if typ in (
"response.reasoning_summary_text.delta",
"response.reasoning_summary_text",
"response.reasoning_text.delta",
"response.reasoning_text",
"response.reasoning_summary.delta",
) or (typ.startswith("response.reasoning") and typ.endswith(".delta")):
rs = _stream_reasoning_delta_text(d)
if rs:
_emit_reasoning_chunk(rs)
continue
# Text deltas
if typ in ("response.output_text.delta", "response.output_text"):
delta = d.get("delta")
if delta is None:
delta = d.get("text")
s = str(delta or "")
if s:
parts.append(s)
if on_token:
on_token(s)
continue
# Sometimes the SDK emits generic `response.delta` with nested segments.
if typ == "response.delta":
delta = d.get("delta")
if isinstance(delta, dict):
txt = delta.get("output_text")
if isinstance(txt, str) and txt:
parts.append(txt)
if on_token:
on_token(txt)
for rkey in ("reasoning_summary_text", "reasoning_text", "reasoning"):
rv = delta.get(rkey)
if isinstance(rv, str) and rv:
_emit_reasoning_chunk(rv)
continue
# Terminal response object
if typ in ("response.completed", "response.complete", "response.done"):
resp = d.get("response") or d.get("data") or d.get("result")
final_resp = _as_dict(resp) or final_resp
continue
if typ == "response.created":
continue
# Some SDK versions yield the response object directly (no explicit event type).
if d.get("output") is not None and d.get("id") is not None:
final_resp = d
text = "".join(parts)
tool_calls = _collect_tool_calls_from_response_dict(final_resp) if final_resp else []
reasoning_text = "".join(reasoning_parts).strip()
if not reasoning_text and final_resp:
reasoning_text = _reasoning_from_completed_response(final_resp)
return text, tool_calls, final_resp, reasoning_text
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,
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:
from openai import OpenAI
except Exception as e:
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
kw: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
kw["base_url"] = self.base_url
self._client = OpenAI(**kw)
@staticmethod
def _strip_leading_system_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Separate leading ``role=system`` rows for ``instructions=` (Responses API expects user-shaped ``input.messages``)."""
msgs = messages or []
sys_chunks: list[str] = []
i = 0
while i < len(msgs):
m = msgs[i]
if not isinstance(m, dict):
break
if str(m.get("role") or "").strip().lower() != "system":
break
c = m.get("content")
if isinstance(c, str) and c.strip():
sys_chunks.append(c.strip())
elif isinstance(c, list):
texts: list[str] = []
for it in c:
if not isinstance(it, dict):
continue
tt = str(it.get("type") or "").strip().lower()
if tt in ("text", "input_text"):
xs = str(it.get("text") or "").strip()
if xs:
texts.append(xs)
elif isinstance(it.get("text"), str) and str(it.get("text")).strip():
texts.append(str(it.get("text")).strip())
if texts:
sys_chunks.append("\n".join(texts))
i += 1
joined = "\n\n".join(sys_chunks).strip()
return joined, list(msgs[i:])
@staticmethod
def _normalize_messages(
messages: list[dict[str, Any]],
*,
envelope_openai_message: bool = True,
content_chat_completions_parts: bool = False,
image_detail_auto: bool = True,
) -> list[dict[str, Any]]:
"""
Build Response input rows used under ``input`` (flat array) or ``input.messages``.
- **Responses content** (default when ``content_chat_completions_parts=False``): each part is
``{"type":"input_text",...}`` / ``{"type":"input_image","image_url":"<str>","detail":"auto"}``
(`OpenAI response_input_*` TypedDict surface).
- **Chat completions content** (``content_chat_completions_parts=True``): ``text`` /
``image_url`` multimodal blocks.
- **Envelope** ``envelope_openai_message=True``: ``{"type":"message","role","content":[...]}``;
``False``: ``{"role","content":[...]}`` (per some Bailian curls).
"""
out: list[dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "user").strip().lower() or "user"
content = m.get("content")
norm_content: list[dict[str, Any]] = []
def _append_image_part(url_value: str) -> None:
if content_chat_completions_parts:
norm_content.append({"type": "image_url", "image_url": {"url": url_value}})
else:
p: dict[str, Any] = {"type": "input_image", "image_url": url_value}
if image_detail_auto:
p["detail"] = "auto"
norm_content.append(p)
def _append_text_part(txt: str) -> None:
if content_chat_completions_parts:
norm_content.append({"type": "text", "text": txt})
else:
norm_content.append({"type": "input_text", "text": txt})
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "input_image":
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
if not b64:
continue
_append_image_part(f"data:{mime};base64,{b64}")
continue
if isinstance(item, dict) and str(item.get("type") or "").strip().lower() == "image_url":
iu = item.get("image_url")
url = ""
if isinstance(iu, dict):
url = str(iu.get("url") or "").strip()
elif isinstance(iu, str):
url = iu.strip()
if url.startswith(("http://", "https://", "data:")):
_append_image_part(url)
continue
if isinstance(item, dict) and item.get("type") in ("input_text", "text"):
t = str(item.get("text") or "").strip()
if t:
_append_text_part(t)
continue
if isinstance(item, dict):
itype = str(item.get("type") or "").strip().lower()
img_key = item.get("image")
if isinstance(img_key, str) and img_key.strip() and itype != "input_image":
uu = img_key.strip()
if uu.startswith(("http://", "https://", "data:")):
_append_image_part(uu)
continue
if isinstance(item.get("text"), str) and str(item.get("text") or "").strip():
_append_text_part(str(item.get("text") or "").strip())
continue
s = str(item.get("text") or "").strip()
if s:
_append_text_part(s)
continue
s = str(item or "").strip()
if s:
_append_text_part(s)
elif isinstance(content, str):
txt = content.strip()
if txt:
_append_text_part(txt)
elif content is not None:
s = str(content).strip()
if s:
_append_text_part(s)
if not norm_content:
continue
coerced_role = "user"
if role != "user":
prefix = "assistant" if role == "assistant" else ("system" if role == "system" else role)
norm_content.insert(
0,
OpenAIResponsesModel._role_prefix_part(prefix, content_chat_completions_parts),
)
if envelope_openai_message:
out.append({"type": "message", "role": coerced_role, "content": norm_content})
else:
out.append({"role": coerced_role, "content": norm_content})
return out
@staticmethod
def _role_prefix_part(prefix: str, chat_parts: bool) -> dict[str, Any]:
t = f"[{prefix}]"
if chat_parts:
return {"type": "text", "text": t}
return {"type": "input_text", "text": t}
@staticmethod
def _responses_input_candidates(
msgs: list[dict[str, Any]],
*,
flat_responses: bool,
prefer_envelope: bool,
prefer_chat_parts: bool,
) -> list[tuple[str, Any]]:
"""Several gateways validate ``input.messages`` differently; try a small deterministic set."""
def N(env: bool, chat: bool) -> list[dict[str, Any]]:
return OpenAIResponsesModel._normalize_messages(
msgs,
envelope_openai_message=env,
content_chat_completions_parts=chat,
)
keys_seen: set[str] = set()
out: list[tuple[str, Any]] = []
def push(tag: str, inp: Any) -> None:
try:
sk = json.dumps(inp, ensure_ascii=False, sort_keys=True, default=str)
except Exception:
sk = repr(inp)
if sk in keys_seen:
return
keys_seen.add(sk)
out.append((tag, inp))
if flat_responses:
for env in (prefer_envelope, not prefer_envelope):
push(f"flat_envelope_{env}", N(env, False))
return out
# Prefer profile/env defaults first (``AIA_RESPONSES_NESTED_CHAT_PARTS`` / envelope toggles), then a fixed
# fallback ring so picky gateways still get Chat-shaped ``input.messages`` without dropping multimodal pixels.
primary_combo = (prefer_envelope, prefer_chat_parts)
fallback_ring = [(False, True), (False, False), (True, False), (True, True)]
combos = [primary_combo] + [p for p in fallback_ring if p != primary_combo]
for env, chat in combos:
norm = N(env, chat)
push(f"e{int(env)}c{int(chat)}_messages", {"messages": norm})
push(f"e{int(env)}c{int(chat)}_flat_input", norm)
return out
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
sys_instructions, rest = OpenAIResponsesModel._strip_leading_system_messages(messages)
def _env_truthy(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
if _env_truthy("AIA_RESPONSES_DEBUG_PRINT_PAYLOAD"):
try:
sys.stderr.write(
"[oclaw openai_responses] chat() ENTERED "
f"model={self.model!r} base_url={self.base_url!r} "
f"messages={len(messages)} tools={len(tools or [])}\n"
)
sys.stderr.flush()
except Exception:
pass
# Default: nested ``input.messages`` rows use **Responses** parts (``input_text`` / ``input_image``).
# Flat Bailian ``input`` array: ``AIA_RESPONSES_INPUT_IS_FLAT_LIST=1`` (optional ``AIA_RESPONSES_FLAT_OPENAI_ENVELOPE``).
flat_raw = os.getenv("AIA_RESPONSES_INPUT_IS_FLAT_LIST")
if flat_raw is not None and str(flat_raw).strip():
flat_responses = _env_truthy("AIA_RESPONSES_INPUT_IS_FLAT_LIST")
else:
legacy = os.getenv("AIA_RESPONSES_INPUT_USE_MESSAGES_ARRAY")
if legacy is None or not str(legacy).strip():
flat_responses = False
else:
flat_responses = not _env_truthy("AIA_RESPONSES_INPUT_USE_MESSAGES_ARRAY")
# Nested ``input.messages`` defaults to **Responses** content parts (``input_*``) + ``type:message``,
# matching OpenAI ``EasyInputMessageParam`` / Model Studio expanded examples. Some proxies wrongly
# expect Chat ``text``/``image_url`` parts → ``AIA_RESPONSES_NESTED_CHAT_PARTS=1``.
if flat_responses:
envelope_openai_message = _env_truthy("AIA_RESPONSES_FLAT_OPENAI_ENVELOPE")
nested_chat_parts = False
else:
envelope_openai_message = True
nested_chat_parts = _env_truthy("AIA_RESPONSES_NESTED_CHAT_PARTS")
def _normalize_batch(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
return OpenAIResponsesModel._normalize_messages(
msgs,
envelope_openai_message=envelope_openai_message,
content_chat_completions_parts=nested_chat_parts,
)
msgs_for_norm: list[dict[str, Any]]
if rest:
msgs_for_norm = rest
norm = _normalize_batch(rest)
inst_kw: dict[str, Any] = {}
if sys_instructions.strip():
si = sys_instructions.strip()
if len(si) > 80_000:
si = si[:80_000] + "\n...[truncated]"
inst_kw = {"instructions": si}
if not norm:
msgs_for_norm = messages
norm = _normalize_batch(messages)
inst_kw = {}
else:
msgs_for_norm = messages
norm = _normalize_batch(messages)
inst_kw = {}
explicit_variants = os.getenv("AIA_RESPONSES_INPUT_VARIANTS")
bu_norm = (self.base_url or "").strip().lower()
# CRITICAL: do **not** treat empty ``base_url`` as "official" here. Clients often omit storing the
# default URL in profiles while still hitting third-party gateways via env/SDK defaults—but then we
# would wrongly disable alternate ``input`` shapes and only ever send ``primary``.
host_is_explicit_official_api = bool(bu_norm) and ("api.openai.com" in bu_norm)
if explicit_variants is not None and str(explicit_variants).strip():
multi_shape = _env_truthy("AIA_RESPONSES_INPUT_VARIANTS")
else:
multi_shape = not host_is_explicit_official_api
if flat_responses:
primary_input: Any = norm
else:
primary_input = {"messages": norm}
input_candidates: list[tuple[str, Any]]
if multi_shape and not _env_truthy("AIA_RESPONSES_DISABLE_INPUT_VARIANTS"):
input_candidates = OpenAIResponsesModel._responses_input_candidates(
msgs_for_norm,
flat_responses=flat_responses,
prefer_envelope=envelope_openai_message,
prefer_chat_parts=nested_chat_parts,
)
else:
input_candidates = [("primary", primary_input)]
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 = ""
base_stream_template: dict[str, Any] = {
**thinking,
**inst_kw,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"tools": tools or None,
"stream": True,
}
try:
for cand_label, responses_input in input_candidates:
stream_payload = {**base_stream_template, "input": responses_input}
if _env_truthy("AIA_RESPONSES_LOG_PAYLOAD_SUMMARY"):
logger.warning(
"openai_responses [%s] payload summary (redacted): %s",
cand_label,
_redact_nested_json_preview(
{
"model": self.model,
"input": responses_input,
"has_instructions": bool(inst_kw.get("instructions")),
"tools_n": len(tools or []),
"stream": True,
}
),
)
payload = stream_payload
_log_openai_responses_wire_kwargs(payload, variant=str(cand_label), phase="stream")
try:
stream = self._client.responses.create(**payload)
text, tool_calls, final_resp, reasoning_text = 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)
if cand_label != input_candidates[0][0]:
logger.info("openai_responses: succeeded with input variant %s", cand_label)
return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text)
except Exception as 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)
_log_openai_responses_wire_kwargs(
forced,
variant=f"{cand_label}_thinking_disabled",
phase="stream_retry_thinking",
)
stream = self._client.responses.create(**forced)
text, tool_calls, final_resp, reasoning_text = 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)
if cand_label != input_candidates[0][0]:
logger.info("openai_responses: succeeded with input variant %s", cand_label)
return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text)
except Exception:
pass
if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"):
eb = getattr(exc, "body", None)
if eb is not None:
logger.warning(
"responses.create stream [%s] body: %s", cand_label, eb
)
if len(input_candidates) > 1 and _is_input_messages_validation_error(exc):
stream_errors.append(f"{cand_label}:{emsg}")
continue
stream_errors.append(emsg)
break
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] = []
base_nonstream = {
**thinking,
**inst_kw,
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
"model": self.model,
"tools": tools or None,
}
for cand_label, responses_input in input_candidates:
payload = {**base_nonstream, "input": responses_input}
_log_openai_responses_wire_kwargs(payload, variant=str(cand_label), phase="non_stream")
try:
resp = self._client.responses.create(**payload)
d = _as_dict(resp) or {}
text = str(d.get("output_text") or "")
tool_calls = _collect_tool_calls_from_response_dict(d)
reasoning_text = _reasoning_from_completed_response(d)
if on_token and text:
on_token(text)
if cand_label != input_candidates[0][0]:
logger.info("openai_responses non-stream: succeeded with input variant %s", cand_label)
return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text)
except Exception as 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)
_log_openai_responses_wire_kwargs(
forced,
variant=f"{cand_label}_thinking_disabled",
phase="non_stream_retry_thinking",
)
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)
reasoning_text = _reasoning_from_completed_response(d)
if on_token and text:
on_token(text)
if cand_label != input_candidates[0][0]:
logger.info(
"openai_responses non-stream: succeeded with input variant %s",
cand_label,
)
return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text)
except Exception:
pass
if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"):
eb = getattr(e2, "body", None)
if eb is not None:
logger.warning(
"responses.create non-stream [%s] body: %s", cand_label, eb
)
if len(input_candidates) > 1 and _is_input_messages_validation_error(e2):
nonstream_errors.append(f"{cand_label}:{emsg2}")
continue
nonstream_errors.append(emsg2)
break
raise RuntimeError(
"openai_responses_request_failed: "
+ " | ".join([str(exc)] + nonstream_errors[-2:])
) from exc
__all__ = ["OpenAIResponsesModel", "parse_openai_responses_stream_events"]

View file

@ -0,0 +1,144 @@
from __future__ import annotations
import json
import re
from typing import Any, Optional
from collections.abc import Callable
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
class StaticTextChatModel(ChatModel):
def __init__(self, text: str):
self._text = text or ""
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
if on_token and self._text:
on_token(self._text)
return LLMResponse(content=self._text, tool_calls=[])
class RuleBasedChatModel(ChatModel):
_IP_RE = re.compile(
r"(?P<ip>(?:\d{1,3}\.){3}\d{1,3})|(?P<ipv6>(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4})"
)
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
def make_call(name: str, arguments: dict[str, Any]) -> LLMResponse:
return LLMResponse(
content="",
tool_calls=[LLMToolCall(id=f"rb_{name}_1", name=name, arguments=arguments)],
)
def emit(resp: LLMResponse) -> LLMResponse:
if on_token and (resp.content or "").strip():
on_token(resp.content)
return resp
if messages:
last = messages[-1]
if last.get("role") == "tool":
raw = str(last.get("content") or "")
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = {"ok": True, "raw": raw}
if isinstance(data, dict) and data.get("ok") is False:
return emit(LLMResponse(content=f"工具执行失败:{data.get('error')}", tool_calls=[]))
if isinstance(data, dict) and "route" in data:
r = data.get("route") or {}
return emit(
LLMResponse(
content=f"路由查询结果:目的 {data.get('destination')} 命中 {r.get('prefix')},下一跳 {r.get('next_hop')},出接口 {r.get('out_if')}。",
tool_calls=[],
)
)
if isinstance(data, dict) and "hops" in data:
hops = data.get("hops") or []
return emit(
LLMResponse(
content=f"路径计算结果:{data.get('src')} → {data.get('dst')} 共 {data.get('hop_count')} 跳,路径为:{' -> '.join(map(str, hops))}",
tool_calls=[],
)
)
if isinstance(data, dict) and "diff" in data:
diff = data.get("diff") or ""
if not diff:
return emit(LLMResponse(content="配置对比结果:两段配置一致。", tool_calls=[]))
return emit(LLMResponse(content=f"配置对比结果:\n\n```diff\n{diff}\n```", tool_calls=[]))
if isinstance(data, dict) and "level_count" in data:
lc = data.get("level_count") or {}
return emit(LLMResponse(content=f"日志分析结果:统计 {lc}。建议优先查看 ERROR 样例与日志尾部定位根因。", tool_calls=[]))
if isinstance(data, dict) and "reachable" in data:
reach = bool(data.get("reachable"))
avg = data.get("avg_ms")
if reach:
tail = f"平均时延 {avg}ms" if isinstance(avg, int) else "时延解析失败"
return emit(LLMResponse(content=f"设备状态:可达,{tail}。", tool_calls=[]))
return emit(LLMResponse(content="设备状态:不可达。建议检查 ACL/路由/DNS/防火墙策略,并确认目标在线。", tool_calls=[]))
return emit(LLMResponse(content=f"工具执行结果:{json.dumps(data, ensure_ascii=False)}", tool_calls=[]))
last_user = ""
for m in reversed(messages):
if m.get("role") == "user":
content = m.get("content")
if isinstance(content, list):
texts = [item["text"] for item in content if isinstance(item, dict) and item.get("type") == "text"]
last_user = "\n".join(texts)
else:
last_user = str(content or "")
break
m = self._IP_RE.search(last_user)
if "路由" in last_user and m:
ip = m.group("ip") or m.group("ipv6")
return make_call("query_route", {"destination": ip})
if ("路径" in last_user or "链路" in last_user) and ("到" in last_user or "->" in last_user):
m2 = re.search(r"([A-Za-z0-9_-]+)\s*(?:到|->)\s*([A-Za-z0-9_-]+)", last_user)
if m2:
src, dst = m2.group(1), m2.group(2)
return make_call("get_path", {"src": src, "dst": dst})
if "对比" in last_user or "diff" in last_user.lower():
if "left:" in last_user.lower() and "right:" in last_user.lower():
left = last_user.split("left:", 1)[1].split("right:", 1)[0].strip()
right = last_user.split("right:", 1)[1].strip()
return make_call("config_diff", {"left": left, "right": right})
if "日志" in last_user and ("分析" in last_user or "统计" in last_user):
return make_call("log_analysis", {"log": last_user})
if ("设备" in last_user or "主机" in last_user) and ("状态" in last_user or "可达" in last_user or "ping" in last_user.lower()) and m:
ip = m.group("ip") or m.group("ipv6")
return make_call("device_status", {"host": ip})
return emit(
LLMResponse(
content="我可以进行路由查询、路径计算、配置对比、设备状态检查、日志分析。请直接描述需求并提供必要输入。",
tool_calls=[],
)
)
__all__ = ["StaticTextChatModel", "RuleBasedChatModel"]