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

1
svc/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Shared services layer (LLM transports, persistence, configuration, files)."""

0
svc/config/__init__.py Normal file
View file

View file

@ -0,0 +1,90 @@
"""Load optional env files before the rest of the app reads ``os.environ``.
Only ``_local/system.env`` (next to the committed template ``_local/system.env.example``) is read.
Variables already set in the process environment are never overwritten (shell/export wins).
新增进程环境变量时:必须在 ``_local/system.env.example`` 用中文登记说明(见该文件顶部的仓库约定)。
"""
from __future__ import annotations
import os
from pathlib import Path
_LOADED = False
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _parse_env_file(path: Path) -> dict[str, str | None]:
out: dict[str, str | None] = {}
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return out
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
if s.startswith("export "):
s = s[7:].strip()
if "=" not in s:
continue
k, _, rest = s.partition("=")
key = k.strip()
if not key:
continue
val = rest.strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
val = val[1:-1]
out[key] = val
return out
def load_system_env(*, force: bool = False) -> list[str]:
"""Merge env files into ``os.environ`` for keys not already set.
Returns the list of existing files that contributed (merged order).
"""
global _LOADED
if _LOADED and not force:
return []
root = _project_root()
candidates = [root / "_local" / "system.env"]
merged: dict[str, str | None] = {}
dotenv_values = None
try:
from dotenv import dotenv_values as _dv # type: ignore
dotenv_values = _dv
except ImportError:
pass
loaded_paths: list[str] = []
for p in candidates:
if not p.is_file():
continue
loaded_paths.append(str(p.resolve()))
if dotenv_values is not None:
vals = dotenv_values(p)
for k, v in vals.items():
merged[str(k)] = v
else:
merged.update(_parse_env_file(p))
for key, val in merged.items():
if not key or key in os.environ:
continue
if val is None:
continue
os.environ[str(key)] = str(val)
_LOADED = True
return loaded_paths
__all__ = ["load_system_env"]

25
svc/config/passwords.py Normal file
View file

@ -0,0 +1,25 @@
"""访问密码解析(环境变量 + 数据库);Streamlit secrets 在认证模块中处理。"""
from __future__ import annotations
import os
from svc.persistence.sqlite_store import SqliteStore
_DEFAULT_BOOTSTRAP_PASSWORD = "admin123"
def load_expected_password(store: SqliteStore, *, extra_candidate: str | None = None) -> str | None:
pwd = (os.getenv("AIA_ASSISTANT_PASSWORD") or "").strip()
if not pwd:
pwd = (os.getenv("OPS_ASSISTANT_PASSWORD") or "").strip()
if not pwd and extra_candidate:
pwd = extra_candidate.strip()
if not pwd:
pwd = (store.get_secret("auth_password") or "").strip()
if not pwd:
pwd = _DEFAULT_BOOTSTRAP_PASSWORD
return pwd if pwd else None
__all__ = ["load_expected_password"]

200
svc/config/paths.py Normal file
View file

@ -0,0 +1,200 @@
"""仓库与数据文件路径解析(与框架无关,供 ``db`` / UI 等共用)。"""
from __future__ import annotations
import os
import shutil
import sqlite3
import sys
import time
import warnings
from pathlib import Path
def _project_root() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
# Source layout: <repo>/svc/config/paths.py
return Path(__file__).resolve().parents[2]
PROJECT_ROOT = _project_root()
_DATA_MIGRATION_DONE = False
def _canonical_data_root() -> Path:
# Compatibility: some callers/tests set PROJECT_ROOT to repo parent while
# data lives under "<root>/oclaw/data".
nested = (PROJECT_ROOT / "oclaw" / "data").resolve()
if nested.exists():
return nested
return (PROJECT_ROOT / "data").resolve()
def _legacy_platform_data_root() -> Path:
return (PROJECT_ROOT / "src" / "platform" / "data").resolve()
def _legacy_root_data_root() -> Path:
return (PROJECT_ROOT.parent / "data").resolve()
def _backup_keep_count() -> int:
raw = (os.getenv("AIA_ASSISTANT_PREMERGE_BACKUP_KEEP") or os.getenv("OPS_ASSISTANT_PREMERGE_BACKUP_KEEP") or "").strip()
if not raw:
return 3
try:
val = int(raw)
except Exception:
return 3
return max(0, val)
def _cleanup_pre_merge_backups() -> None:
root = _canonical_data_root()
if not root.exists():
return
keep = _backup_keep_count()
backups = sorted([p for p in root.glob("_pre_merge_sqlite_*") if p.is_dir()], key=lambda p: p.name, reverse=True)
for old in backups[keep:]:
try:
shutil.rmtree(old)
except Exception as exc:
warnings.warn(f"自动清理旧备份失败(已跳过): {old} ({exc})", RuntimeWarning, stacklevel=1)
def attachments_dir() -> Path:
p = (_canonical_data_root() / "attachments").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _sqlite_sidecars(main: Path) -> list[Path]:
return [main, Path(str(main) + "-wal"), Path(str(main) + "-shm")]
def _sqlite_chat_session_count(db: Path) -> int | None:
if not db.exists():
return None
try:
conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=3.0)
try:
r = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='chat_session'").fetchone()
if not r or int(r[0] or 0) == 0:
return 0
row = conn.execute("SELECT COUNT(*) FROM chat_session").fetchone()
return int(row[0] or 0) if row else 0
finally:
conn.close()
except Exception:
return None
def _checkpoint_wal_best_effort(main: Path) -> None:
try:
conn = sqlite3.connect(str(main), timeout=5.0)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
finally:
conn.close()
except Exception:
pass
def _copy_sqlite_bundle(src_main: Path, dst_main: Path) -> None:
dst_main.parent.mkdir(parents=True, exist_ok=True)
_checkpoint_wal_best_effort(src_main)
for s in _sqlite_sidecars(src_main):
if not s.exists():
continue
d = dst_main.parent / s.name
if s.resolve() == d.resolve():
continue
shutil.copy2(s, d)
def _backup_sqlite_bundle_copy(main: Path, dest_dir: Path) -> None:
dest_dir.mkdir(parents=True, exist_ok=True)
_checkpoint_wal_best_effort(main)
for s in _sqlite_sidecars(main):
if s.exists():
shutil.copy2(s, dest_dir / s.name)
def _merge_attachment_tree(src: Path, dst: Path) -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
copied = 0
for f in src.rglob("*"):
if not f.is_file():
continue
rel = f.relative_to(src)
target = dst / rel
if target.exists():
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, target)
copied += 1
return copied
def _run_default_data_migration(canonical_db: Path) -> None:
legacy_root_data = _legacy_root_data_root()
legacy_root_db = legacy_root_data / "ai_ops.sqlite"
if legacy_root_db.exists() and not canonical_db.exists():
_copy_sqlite_bundle(legacy_root_db, canonical_db)
_merge_attachment_tree(legacy_root_data / "attachments", attachments_dir())
legacy_platform_data = _legacy_platform_data_root()
legacy_db = legacy_platform_data / "ai_ops.sqlite"
if legacy_db.exists():
leg_n = _sqlite_chat_session_count(legacy_db)
if leg_n is not None:
if not canonical_db.exists():
_copy_sqlite_bundle(legacy_db, canonical_db)
else:
can_n = _sqlite_chat_session_count(canonical_db)
can_n = 0 if can_n is None else can_n
force = str(os.getenv("AIA_LEGACY_DB_FORCE_PREMERGE") or os.getenv("OPS_LEGACY_DB_FORCE_PREMERGE") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
if force and leg_n > can_n:
ts = time.strftime("%Y%m%d_%H%M%S")
bak = _canonical_data_root() / f"_pre_merge_sqlite_{ts}"
try:
_checkpoint_wal_best_effort(canonical_db)
_backup_sqlite_bundle_copy(canonical_db, bak)
_copy_sqlite_bundle(legacy_db, canonical_db)
except (OSError, PermissionError, shutil.Error) as exc:
warnings.warn(
"未能用旧库覆盖当前主库(可能被其它进程占用)。"
f"已备份当前库到: {bak}。可关闭占用后重启,或手动合并。原因: {exc}",
RuntimeWarning,
stacklevel=1,
)
_merge_attachment_tree(legacy_platform_data / "attachments", attachments_dir())
_cleanup_pre_merge_backups()
def db_path() -> str:
global _DATA_MIGRATION_DONE
p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "data/ai_ops.sqlite"
path = Path(p)
if not path.is_absolute():
path = (PROJECT_ROOT / path).resolve()
use_default = not (os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH"))
if use_default and not _DATA_MIGRATION_DONE:
_DATA_MIGRATION_DONE = True
try:
_run_default_data_migration(path)
except Exception as exc:
warnings.warn(f"默认数据目录迁移未完全成功(可稍后重试或检查权限): {exc}", RuntimeWarning, stacklevel=1)
return str(path)
__all__ = ["PROJECT_ROOT", "attachments_dir", "db_path"]

View file

@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
from svc.config.paths import PROJECT_ROOT
def oclaw_root() -> Path:
return Path(PROJECT_ROOT).resolve()
def runtime_root() -> Path:
return (oclaw_root() / "runtime").resolve()
def runtime_skills_root() -> Path:
"""Canonical skills tree lives at repo ``skills/``; ``runtime/skills`` remains a migration fallback."""
root_skills = (oclaw_root() / "skills").resolve()
legacy_runtime_skills = (runtime_root() / "skills").resolve()
if root_skills.is_dir():
return root_skills
if legacy_runtime_skills.is_dir():
return legacy_runtime_skills
return root_skills
def runtime_hooks_root() -> Path:
return (runtime_root() / "hooks").resolve()
def runtime_hooks_bundled_root() -> Path:
return (runtime_hooks_root() / "bundled").resolve()
def runtime_extensions_root() -> Path:
return (runtime_root() / "extensions").resolve()
def runtime_operations_scripts_root() -> Path:
return (runtime_root() / "operations" / "scripts").resolve()
def ws_protocol_schemas_root() -> Path:
return (oclaw_root() / "interfaces" / "ws" / "protocol_schemas").resolve()
__all__ = [
"oclaw_root",
"runtime_root",
"runtime_skills_root",
"runtime_hooks_root",
"runtime_hooks_bundled_root",
"runtime_extensions_root",
"runtime_operations_scripts_root",
"ws_protocol_schemas_root",
]

View file

@ -0,0 +1,77 @@
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class EmbeddingResult:
model: str
vector: list[float]
class EmbeddingClient:
def embed(self, text: str) -> EmbeddingResult:
raise NotImplementedError
class OpenAIEmbeddingClient(EmbeddingClient):
def __init__(self, *, model: str | None = None, api_key: str | None = None, base_url: str | None = None):
self.model = (model or os.getenv("OPENAI_EMBEDDING_MODEL") or "text-embedding-3-small").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
if not self.api_key:
raise RuntimeError("OPENAI_API_KEY is required for embeddings")
from openai import OpenAI
kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
kwargs["base_url"] = self.base_url
self._client = OpenAI(**kwargs)
def embed(self, text: str) -> EmbeddingResult:
t = (text or "").strip()
if not t:
return EmbeddingResult(model=self.model, vector=[0.0] * 8)
resp = self._client.embeddings.create(model=self.model, input=t)
vec = list(resp.data[0].embedding)
return EmbeddingResult(model=self.model, vector=[float(x) for x in vec])
class HashEmbeddingClient(EmbeddingClient):
"""Offline fallback: deterministic small vector (NOT semantic)."""
def __init__(self, *, dim: int = 32, model: str = "hash-embed-32"):
self.dim = int(dim)
self.model = model
def embed(self, text: str) -> EmbeddingResult:
t = (text or "").encode("utf-8", errors="ignore")
h = hashlib.sha256(t).digest()
vec = []
for i in range(self.dim):
b = h[i % len(h)]
vec.append((float(b) / 255.0) * 2.0 - 1.0)
return EmbeddingResult(model=self.model, vector=vec)
def build_default_embedding_client() -> EmbeddingClient:
mode = (os.getenv("AIA_RAG_EMBEDDING_MODE") or "").strip().lower()
if mode in ("hash", "offline"):
return HashEmbeddingClient()
try:
return OpenAIEmbeddingClient()
except Exception:
return HashEmbeddingClient()
__all__ = [
"EmbeddingClient",
"EmbeddingResult",
"OpenAIEmbeddingClient",
"HashEmbeddingClient",
"build_default_embedding_client",
]

0
svc/files/__init__.py Normal file
View file

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import gzip
import io
import tarfile
import zipfile
from pathlib import PurePosixPath
from typing import Any, Callable
MAX_ARCHIVE_DEPTH = 2
MAX_ARCHIVE_FILE_COUNT = 200
MAX_ARCHIVE_ENTRY_BYTES = 10 * 1024 * 1024
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
MAX_ARCHIVE_MEMBER_NAME_LENGTH = 240
def is_safe_archive_member_name(name: str) -> bool:
n = str(name or "").replace("\\", "/").strip()
if not n:
return False
if len(n) > MAX_ARCHIVE_MEMBER_NAME_LENGTH:
return False
if any(ord(ch) < 32 for ch in n):
return False
p = PurePosixPath(n)
if p.is_absolute():
return False
if any(part in ("..", "") for part in p.parts):
return False
return True
def detect_archive_kind(name: str) -> str | None:
n = str(name or "").strip().lower()
if n.endswith(".tar.gz") or n.endswith(".tgz"):
return "tgz"
if n.endswith(".zip"):
return "zip"
if n.endswith(".tar"):
return "tar"
if n.endswith(".gz"):
return "gz"
if n.endswith(".rar"):
return "rar"
if n.endswith(".7z"):
return "7z"
return None
def sniff_archive_kind(data: bytes) -> str | None:
b = bytes(data or b"")
if len(b) >= 4 and b[:4] == b"PK\x03\x04":
return "zip"
if len(b) >= 2 and b[:2] == b"\x1f\x8b":
return "gz"
if len(b) >= 6 and b[:6] == b"Rar!\x1a\x07":
return "rar"
if len(b) >= 6 and b[:6] == b"7z\xbc\xaf\x27\x1c":
return "7z"
if len(b) >= 262 and b[257:262] == b"ustar":
return "tar"
return None
def process_archive(
*,
archive_name: str,
archive_data: bytes,
process_member: Callable[[str, bytes, int], list[dict[str, Any]]],
depth: int = 0,
limits: dict[str, int] | None = None,
) -> list[dict[str, Any]]:
lim = limits or {}
max_depth = int(lim.get("max_depth") or MAX_ARCHIVE_DEPTH)
max_file_count = int(lim.get("max_file_count") or MAX_ARCHIVE_FILE_COUNT)
max_entry_bytes = int(lim.get("max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES)
max_total_uncompressed_bytes = int(
lim.get("max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
)
kind_from_sig = sniff_archive_kind(archive_data)
kind_from_name = detect_archive_kind(archive_name)
# For .tgz/.tar.gz, signature looks like gzip; keep richer name hint.
if kind_from_name == "tgz" and kind_from_sig == "gz":
kind = "tgz"
else:
kind = kind_from_sig or kind_from_name
if kind is None:
return []
if depth > max_depth:
return [{"type": "text", "name": "zip-error", "content": f"ZIP nesting too deep (>{max_depth})"}]
out: list[dict[str, Any]] = []
def _err(msg: str, *, error_code: str) -> list[dict[str, Any]]:
base = {
"type": "text",
"name": ("zip-error" if kind == "zip" else "archive-error"),
"error_code": str(error_code or "archive_error"),
"content": (
f"[ArchiveError]\n- error_code: {error_code}\n- format: {kind}\n- detail: {msg}"
),
}
return [base]
try:
if kind in {"rar", "7z"}:
raise ValueError(
f"unsupported archive format: {kind}; currently supported formats are zip/tar/tgz/gz"
)
if kind == "zip":
with zipfile.ZipFile(io.BytesIO(archive_data)) as zf:
infos = [x for x in zf.infolist() if not x.is_dir()]
if len(infos) > max_file_count:
raise ValueError(f"ZIP has too many files ({len(infos)} > {max_file_count})")
total_uncompressed = 0
for info in infos:
if not is_safe_archive_member_name(info.filename):
raise ValueError(f"ZIP contains unsafe path: {info.filename}")
fsize = int(info.file_size or 0)
total_uncompressed += fsize
if fsize > max_entry_bytes:
raise ValueError(f"ZIP entry too large: {info.filename} ({fsize} > {max_entry_bytes})")
if total_uncompressed > max_total_uncompressed_bytes:
raise ValueError(
f"ZIP total uncompressed size too large ({total_uncompressed} > {max_total_uncompressed_bytes})"
)
with zf.open(info) as f:
out.extend(process_member(info.filename, f.read(), depth + 1))
return out
if kind in {"tar", "tgz"}:
mode = "r:gz" if kind == "tgz" else "r:"
with tarfile.open(fileobj=io.BytesIO(archive_data), mode=mode) as tf:
all_members = list(tf.getmembers())
for m in all_members:
if m.issym() or m.islnk():
raise ValueError(f"TAR contains link entry: {m.name}")
if m.ischr() or m.isblk() or m.isfifo():
raise ValueError(f"TAR contains device/fifo entry: {m.name}")
members = [m for m in all_members if m.isfile()]
if len(members) > max_file_count:
raise ValueError(f"TAR has too many files ({len(members)} > {max_file_count})")
total_uncompressed = 0
for m in members:
if not is_safe_archive_member_name(m.name):
raise ValueError(f"TAR contains unsafe path: {m.name}")
fsize = int(m.size or 0)
total_uncompressed += fsize
if fsize > max_entry_bytes:
raise ValueError(f"TAR entry too large: {m.name} ({fsize} > {max_entry_bytes})")
if total_uncompressed > max_total_uncompressed_bytes:
raise ValueError(
f"TAR total uncompressed size too large ({total_uncompressed} > {max_total_uncompressed_bytes})"
)
ext = tf.extractfile(m)
if ext is None:
continue
out.extend(process_member(m.name, ext.read(), depth + 1))
return out
if kind == "gz":
raw = gzip.decompress(archive_data)
if len(raw) > max_entry_bytes:
raise ValueError(f"GZ entry too large ({len(raw)} > {max_entry_bytes})")
name = str(archive_name or "file.gz")
inner_name = name[:-3] if name.lower().endswith(".gz") else f"{name}.out"
out.extend(process_member(inner_name, raw, depth + 1))
return out
except Exception as e:
s = str(e).lower()
code = "archive_parse_failed"
if "unsupported archive format" in s:
code = "archive_unsupported_format"
elif "unsafe path" in s:
code = "archive_path_traversal"
elif "nesting too deep" in s:
code = "archive_max_depth_exceeded"
elif "too many files" in s:
code = "archive_max_file_count_exceeded"
elif "entry too large" in s:
code = "archive_max_entry_bytes_exceeded"
elif "total uncompressed size too large" in s:
code = "archive_max_total_uncompressed_bytes_exceeded"
elif "link entry" in s:
code = "archive_link_entry_forbidden"
elif "device/fifo entry" in s:
code = "archive_special_entry_forbidden"
return _err(str(e), error_code=code)
return out
__all__ = [
"MAX_ARCHIVE_DEPTH",
"MAX_ARCHIVE_ENTRY_BYTES",
"MAX_ARCHIVE_FILE_COUNT",
"MAX_ARCHIVE_MEMBER_NAME_LENGTH",
"MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES",
"detect_archive_kind",
"sniff_archive_kind",
"is_safe_archive_member_name",
"process_archive",
]

View file

@ -0,0 +1,378 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Final, Optional
from svc.config.paths import attachments_dir
_META_SUFFIX: Final[str] = ".meta.json"
_ATTACHMENT_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-f0-9]{64}$")
# Must match extensions produced by ``save_bytes`` so ``load_bytes`` / ``get_local_path`` / ``gc`` can find blobs.
_ATTACHMENT_BLOB_EXTENSIONS: Final[tuple[str, ...]] = (
".png",
".jpg",
".jpeg",
".webp",
".gif",
".mp4",
".webm",
".mov",
"", # extensionless fallback
)
def _utc_ts() -> int:
return int(time.time())
def _safe_rel_name(name: str) -> str:
# keep it simple: drop path parts, trim, and avoid empty
raw = (name or "").replace("\\", "/").split("/")[-1].strip()
return raw or "image"
def _ext_from_mime(mime: str | None) -> str:
m = (mime or "").strip().lower()
if m == "image/png":
return ".png"
if m in ("image/jpg", "image/jpeg"):
return ".jpg"
if m == "image/webp":
return ".webp"
if m == "image/gif":
return ".gif"
if m == "video/mp4":
return ".mp4"
if m == "video/webm":
return ".webm"
if m in ("video/quicktime", "video/mov"):
return ".mov"
return ""
def _guess_mime_from_ext(ext: str) -> str:
e = (ext or "").lower().lstrip(".")
if e == "png":
return "image/png"
if e in ("jpg", "jpeg"):
return "image/jpeg"
if e == "webp":
return "image/webp"
if e == "gif":
return "image/gif"
if e == "mp4":
return "video/mp4"
if e == "webm":
return "video/webm"
if e == "mov":
return "video/quicktime"
return "application/octet-stream"
@dataclass(frozen=True)
class AttachmentMeta:
attachment_id: str
name: str
mime: str
bytes: int
created_at: int
last_access: int
width: int | None = None
height: int | None = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"attachment_id": self.attachment_id,
"name": self.name,
"mime": self.mime,
"bytes": int(self.bytes),
"created_at": int(self.created_at),
"last_access": int(self.last_access),
}
if self.width is not None:
d["width"] = int(self.width)
if self.height is not None:
d["height"] = int(self.height)
return d
@staticmethod
def from_dict(d: dict[str, Any]) -> "AttachmentMeta":
return AttachmentMeta(
attachment_id=str(d.get("attachment_id") or ""),
name=str(d.get("name") or ""),
mime=str(d.get("mime") or "application/octet-stream"),
bytes=int(d.get("bytes") or 0),
created_at=int(d.get("created_at") or 0),
last_access=int(d.get("last_access") or 0),
width=int(d["width"]) if d.get("width") is not None else None,
height=int(d["height"]) if d.get("height") is not None else None,
)
class AttachmentAssetStore:
"""Disk-backed attachment store (project-local by default)."""
def __init__(self, root_dir: str | Path | None = None):
self.root = Path(root_dir) if root_dir is not None else attachments_dir()
self.root.mkdir(parents=True, exist_ok=True)
@staticmethod
def _normalize_attachment_id(attachment_id: str) -> str:
aid = str(attachment_id or "").strip().lower()
if not _ATTACHMENT_ID_RE.fullmatch(aid):
raise ValueError("attachment_id_invalid")
return aid
def _data_path(self, attachment_id: str, *, ext: str) -> Path:
# bucket to avoid huge single dir
aid = self._normalize_attachment_id(attachment_id)
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
d = self.root / p1 / p2
d.mkdir(parents=True, exist_ok=True)
return d / f"{aid}{ext}"
def _meta_path(self, attachment_id: str) -> Path:
aid = self._normalize_attachment_id(attachment_id)
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
d = self.root / p1 / p2
d.mkdir(parents=True, exist_ok=True)
return d / f"{aid}{_META_SUFFIX}"
def save_bytes(
self,
data: bytes,
*,
filename: str,
mime: str | None,
width: int | None = None,
height: int | None = None,
) -> AttachmentMeta:
blob = bytes(data or b"")
h = hashlib.sha256(blob).hexdigest()
ext = _ext_from_mime(mime) or (("." + filename.split(".")[-1].lower()) if "." in filename else "")
ext = ext if ext in _ATTACHMENT_BLOB_EXTENSIONS[:-1] else _ext_from_mime(mime) or ""
data_path = self._data_path(h, ext=ext)
meta_path = self._meta_path(h)
now = _utc_ts()
if not data_path.exists():
tmp = str(data_path) + ".tmp"
with open(tmp, "wb") as f:
f.write(blob)
os.replace(tmp, data_path)
meta = AttachmentMeta(
attachment_id=h,
name=_safe_rel_name(filename),
mime=(mime or _guess_mime_from_ext(ext)).strip() or _guess_mime_from_ext(ext),
bytes=len(blob),
created_at=now,
last_access=now,
width=width,
height=height,
)
# best-effort: keep existing created_at if present
if meta_path.exists():
try:
prev = json.loads(meta_path.read_text("utf-8"))
if isinstance(prev, dict) and prev.get("created_at"):
meta = AttachmentMeta(
**{**meta.to_dict(), "created_at": int(prev.get("created_at") or now)} # type: ignore[arg-type]
)
except Exception:
pass
meta_path.write_text(json.dumps(meta.to_dict(), ensure_ascii=False), encoding="utf-8")
return meta
def get_meta(self, attachment_id: str) -> Optional[AttachmentMeta]:
try:
mp = self._meta_path(attachment_id)
except Exception:
return None
if not mp.exists():
return None
try:
obj = json.loads(mp.read_text("utf-8"))
if not isinstance(obj, dict):
return None
return AttachmentMeta.from_dict(obj)
except Exception:
return None
def touch(self, attachment_id: str) -> None:
try:
mp = self._meta_path(attachment_id)
except Exception:
return
if not mp.exists():
return
try:
obj = json.loads(mp.read_text("utf-8"))
if not isinstance(obj, dict):
return
obj["last_access"] = _utc_ts()
mp.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8")
except Exception:
return
def load_bytes(self, attachment_id: str) -> tuple[bytes, Optional[AttachmentMeta]]:
try:
aid = self._normalize_attachment_id(attachment_id)
except Exception:
return b"", None
meta = self.get_meta(aid)
# try find data file by scanning known extensions
data_path = None
for ext in _ATTACHMENT_BLOB_EXTENSIONS:
p = self._data_path(aid, ext=ext)
if p.exists():
data_path = p
break
if data_path is None:
return b"", meta
try:
blob = data_path.read_bytes()
except Exception:
return b"", meta
self.touch(aid)
return blob, meta
def get_local_path(self, attachment_id: str) -> Path | None:
try:
aid = self._normalize_attachment_id(attachment_id)
except Exception:
return None
for ext in _ATTACHMENT_BLOB_EXTENSIONS:
p = self._data_path(aid, ext=ext)
if p.exists():
self.touch(aid)
return p
return None
def inspect_asset(self, attachment_id: str) -> dict[str, Any]:
meta = self.get_meta(attachment_id)
p = self.get_local_path(attachment_id)
if meta is None and p is None:
return {"state": "missing", "attachment_id": attachment_id}
if meta is not None and p is None:
return {
"state": "meta_exists_blob_missing",
"attachment_id": attachment_id,
"meta": meta.to_dict(),
}
return {
"state": "ok",
"attachment_id": attachment_id,
"meta": meta.to_dict() if meta else None,
"path": str(p) if p is not None else "",
}
def get_render_cache_key(self, attachment_id: str, *, mime_hint: str | None = None) -> tuple[str, str, int, int]:
meta = self.get_meta(attachment_id)
return (
str(attachment_id or "").strip(),
str(mime_hint or (meta.mime if meta else "") or "image/jpeg"),
int(meta.bytes if meta else 0),
int(meta.created_at if meta else 0),
)
def gc(self, *, ttl_days: int = 7) -> dict[str, Any]:
ttl_sec = max(1, int(ttl_days)) * 86400
now = _utc_ts()
removed = 0
kept = 0
if not self.root.exists():
return {"ok": True, "removed": 0, "kept": 0}
for mp in self.root.rglob(f"*{_META_SUFFIX}"):
try:
obj = json.loads(mp.read_text("utf-8"))
if not isinstance(obj, dict):
continue
last_access = int(obj.get("last_access") or obj.get("created_at") or 0)
aid = str(obj.get("attachment_id") or mp.name.replace(_META_SUFFIX, ""))
except Exception:
continue
if last_access and (now - last_access) <= ttl_sec:
kept += 1
continue
# delete meta + possible data files
try:
mp.unlink(missing_ok=True) # py3.8+; on 3.14 ok
except Exception:
pass
for ext in _ATTACHMENT_BLOB_EXTENSIONS:
try:
self._data_path(aid, ext=ext).unlink(missing_ok=True)
except Exception:
pass
removed += 1
return {"ok": True, "removed": removed, "kept": kept}
_LAST_GC_TS: int = 0
def gc_if_due(*, ttl_days: int = 7, min_interval_sec: int = 3600, root_dir: str | Path | None = None) -> None:
"""Best-effort throttled GC to be called in UI loop."""
global _LAST_GC_TS
now = _utc_ts()
if _LAST_GC_TS and (now - _LAST_GC_TS) < max(10, int(min_interval_sec)):
return
_LAST_GC_TS = now
try:
AttachmentAssetStore(root_dir).gc(ttl_days=ttl_days)
except Exception:
return
def image_ref_to_data_url(att: dict[str, Any], *, root_dir: str | Path | None = None) -> tuple[str, str]:
"""Helper for UI: convert image_ref to (mime, data_url_base64_payload)."""
aid = str(att.get("attachment_id") or "").strip()
if not aid:
return "image/jpeg", ""
store = AttachmentAssetStore(root_dir)
blob, meta = store.load_bytes(aid)
mime = str(att.get("mime") or (meta.mime if meta else "image/jpeg") or "image/jpeg")
if not blob:
return mime, ""
b64 = base64.b64encode(blob).decode("ascii")
return mime, b64
def attachment_id_to_data_url(
attachment_id: str,
*,
mime: str | None = None,
root_dir: str | Path | None = None,
) -> str:
"""Convert stored attachment bytes into data URL form."""
aid = str(attachment_id or "").strip()
if not aid:
return ""
store = AttachmentAssetStore(root_dir)
blob, meta = store.load_bytes(aid)
if not blob:
return ""
resolved_mime = str(mime or (meta.mime if meta else "") or "image/jpeg")
b64 = base64.b64encode(blob).decode("ascii")
return f"data:{resolved_mime};base64,{b64}"
__all__ = [
"AttachmentAssetStore",
"AttachmentMeta",
"attachment_id_to_data_url",
"gc_if_due",
"image_ref_to_data_url",
]

View file

@ -0,0 +1,776 @@
from __future__ import annotations
"""将上传文件解析为附件字典(不依赖 Streamlit)。"""
import io
import json
import os
import re
import subprocess
import zipfile
from functools import lru_cache
from pathlib import Path
from typing import Any
import pandas as pd
from docx import Document
from PIL import Image
from PyPDF2 import PdfReader
from svc.config.paths import PROJECT_ROOT
from svc.files.archive_processor import (
MAX_ARCHIVE_DEPTH,
MAX_ARCHIVE_ENTRY_BYTES,
MAX_ARCHIVE_FILE_COUNT,
MAX_ARCHIVE_MEMBER_NAME_LENGTH,
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES,
detect_archive_kind,
is_safe_archive_member_name,
process_archive,
)
from svc.files.attachment_assets import AttachmentAssetStore
from svc.files.tabular_attachment_store import save_dataframe, save_workbook
from svc.files.text_attachment_store import (
DEFAULT_TEXT_CHUNK_OVERLAP,
DEFAULT_TEXT_CHUNK_SIZE,
DEFAULT_TEXT_INLINE_MAX_CHARS,
save_text_document,
)
def _sniff_image_mime(data: bytes) -> str | None:
if len(data) < 12:
return None
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if data[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def _ffprobe_exists() -> bool:
try:
p = subprocess.run(["ffprobe", "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3)
return p.returncode == 0
except Exception:
return False
def _extract_video_meta_from_ffprobe(obj: dict[str, Any]) -> dict[str, Any]:
fmt = obj.get("format") if isinstance(obj.get("format"), dict) else {}
streams = obj.get("streams") if isinstance(obj.get("streams"), list) else []
duration_sec = None
if isinstance(fmt, dict) and fmt.get("duration") is not None:
try:
duration_sec = float(fmt.get("duration"))
except Exception:
duration_sec = None
width = None
height = None
fps = None
for s in streams:
if not isinstance(s, dict):
continue
if str(s.get("codec_type") or "") != "video":
continue
if s.get("width") is not None and s.get("height") is not None:
try:
width = int(s.get("width") or 0) or None
height = int(s.get("height") or 0) or None
except Exception:
width = width
height = height
fr = str(s.get("avg_frame_rate") or s.get("r_frame_rate") or "").strip()
if fr and fr != "0/0" and "/" in fr:
try:
a, b = fr.split("/", 1)
fa = float(a)
fb = float(b)
if fb:
fps = fa / fb
except Exception:
fps = fps
break
out: dict[str, Any] = {}
if duration_sec is not None:
out["duration_sec"] = duration_sec
if width is not None:
out["width"] = width
if height is not None:
out["height"] = height
if fps is not None:
out["fps"] = fps
return out
def _ffprobe_video_meta(path: Path) -> dict[str, Any]:
if not _ffprobe_exists():
return {}
try:
p = subprocess.run(
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=8,
)
if p.returncode != 0:
return {}
obj = json.loads(p.stdout or "{}")
if not isinstance(obj, dict):
return {}
return _extract_video_meta_from_ffprobe(obj)
except Exception:
return {}
MAX_TEXT_CHARS = 12_000
EXCEL_PREVIEW_ROWS = 80
LARGE_TABLE_PREVIEW_ROWS = 20
MAX_ZIP_DEPTH = MAX_ARCHIVE_DEPTH
MAX_ZIP_FILE_COUNT = MAX_ARCHIVE_FILE_COUNT
MAX_ZIP_ENTRY_BYTES = MAX_ARCHIVE_ENTRY_BYTES
MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
DEFAULT_TABULAR_ROWS_READ = 5000
DEFAULT_TABULAR_COLUMNS = 200
DEFAULT_TABULAR_CELL_CHARS = 500
DEFAULT_TABULAR_TOOL_MODE_ENABLED = True
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES = 30 * 1024 * 1024
DEFAULT_LARGE_TABLE_PREVIEW_ROWS = LARGE_TABLE_PREVIEW_ROWS
MAX_ZIP_MEMBER_NAME_LENGTH = MAX_ARCHIVE_MEMBER_NAME_LENGTH
MAX_EXCEL_ZIP_FILE_COUNT = 500
MAX_EXCEL_ZIP_ENTRY_BYTES = 20 * 1024 * 1024
MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES = 120 * 1024 * 1024
DEFAULT_MAX_EXCEL_SHEETS = 50
DEFAULT_TEXT_QUERY_TOP_K = 5
def _truncate_text(text: str, limit: int = MAX_TEXT_CHARS) -> str:
t = str(text or "")
if len(t) <= limit:
return t
return t[:limit] + f"\n\n...[truncated to {limit} chars]"
def _text_ref_summary(*, name: str, chars: int, chunks: int, source_kind: str, preview: str, top_k: int) -> str:
return _truncate_text(
(
f"# Text Document Summary\n"
f"- name: {name}\n"
f"- source_kind: {source_kind}\n"
f"- chars: {chars}\n"
f"- chunks: {chunks}\n"
f"- mode: text_ref\n"
f"- query_tool: query_text_attachment\n"
f"- note: use `text_id` from accompanying `text_ref` attachment; recommended top_k <= {top_k}\n\n"
f"## Preview (first {min(len(preview), 1500)} chars)\n"
f"{preview[:1500]}"
),
limit=MAX_TEXT_CHARS,
)
def _decode_text_bytes(data: bytes) -> str:
try:
return data.decode("utf-8")
except UnicodeDecodeError:
try:
return data.decode("gbk")
except Exception:
return "Error decoding text file (unknown encoding)"
def _clean_html_to_text(raw: str) -> str:
text = re.sub(r"(?is)<script[^>]*>.*?</script>", " ", str(raw or ""))
text = re.sub(r"(?is)<style[^>]*>.*?</style>", " ", text)
text = re.sub(r"(?is)<[^>]+>", " ", text)
text = re.sub(r"[ \t\r\f\v]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_000_000) -> int:
try:
value = int(raw)
except Exception:
return default
if value < min_value:
return default
return min(value, max_value)
@lru_cache(maxsize=1)
def _attachments_limits() -> dict[str, Any]:
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw.json").resolve()
if not cfg_path.is_absolute():
cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve()
tabular_cfg: dict[str, Any] = {}
try:
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
tabular_cfg = (
(((obj or {}).get("plugins") or {}).get("entries") or {}).get("memory-wiki", {}).get("auto", {}).get("attachments", {}).get("tabular", {}) or {}
)
except Exception:
tabular_cfg = {}
return {
"rows_read": _safe_int(tabular_cfg.get("max_rows_read"), DEFAULT_TABULAR_ROWS_READ),
"columns": _safe_int(tabular_cfg.get("max_columns"), DEFAULT_TABULAR_COLUMNS),
"cell_chars": _safe_int(tabular_cfg.get("max_cell_chars"), DEFAULT_TABULAR_CELL_CHARS),
"max_excel_sheets": _safe_int(tabular_cfg.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
"large_table_preview_rows": _safe_int(
tabular_cfg.get("large_table_preview_rows"), DEFAULT_LARGE_TABLE_PREVIEW_ROWS, max_value=500
),
"tool_mode_enabled": bool(tabular_cfg.get("tool_mode_enabled", DEFAULT_TABULAR_TOOL_MODE_ENABLED)),
"tool_mode_min_rows": _safe_int(tabular_cfg.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
"tool_mode_max_bytes": _safe_int(
tabular_cfg.get("tool_mode_max_bytes"), DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES, max_value=500 * 1024 * 1024
),
"text_inline_max_chars": _safe_int(
tabular_cfg.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000
),
"text_chunk_size": _safe_int(tabular_cfg.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
"text_chunk_overlap": _safe_int(
tabular_cfg.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000
),
"text_query_top_k": _safe_int(tabular_cfg.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
"archive_max_depth": _safe_int(tabular_cfg.get("archive_max_depth"), MAX_ARCHIVE_DEPTH, max_value=10),
"archive_max_file_count": _safe_int(
tabular_cfg.get("archive_max_file_count"), MAX_ARCHIVE_FILE_COUNT, max_value=20_000
),
"archive_max_entry_bytes": _safe_int(
tabular_cfg.get("archive_max_entry_bytes"), MAX_ARCHIVE_ENTRY_BYTES, max_value=2_000_000_000
),
"archive_max_total_uncompressed_bytes": _safe_int(
tabular_cfg.get("archive_max_total_uncompressed_bytes"),
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES,
max_value=5_000_000_000,
),
}
def clear_attachment_limits_cache() -> None:
_attachments_limits.cache_clear()
def _clip_tabular_cells(df: pd.DataFrame, *, max_cell_chars: int) -> tuple[pd.DataFrame, bool]:
clipped_any = False
def _clip_cell(value: Any) -> Any:
nonlocal clipped_any
if value is None:
return value
s = str(value)
if len(s) <= max_cell_chars:
return value
clipped_any = True
return s[:max_cell_chars] + "...[cell-truncated]"
out = df.copy()
for col in out.columns:
out[col] = out[col].map(_clip_cell)
return out, clipped_any
def _dataframe_summary_text(
df: pd.DataFrame,
*,
sampled: bool = False,
clipped_columns: bool = False,
clipped_cells: bool = False,
preview_rows: int = EXCEL_PREVIEW_ROWS,
) -> str:
rows = int(len(df.index))
cols = int(len(df.columns))
header = [str(x) for x in list(df.columns)]
dtypes = [f"{str(k)}:{str(v)}" for k, v in df.dtypes.items()]
show_rows = max(1, int(preview_rows or EXCEL_PREVIEW_ROWS))
sample = df.head(show_rows).to_string(index=False)
rows_scope_line = (
f"## Preview (first {min(rows, show_rows)} rows)"
if rows > show_rows
else f"## Full table included ({rows} rows)"
)
body = (
f"# Table Summary\n"
f"- rows: {rows}\n"
f"- cols: {cols}\n"
f"- sampled: {'yes' if sampled else 'no'}\n"
f"- clipped_columns: {'yes' if clipped_columns else 'no'}\n"
f"- clipped_cells: {'yes' if clipped_cells else 'no'}\n"
f"- columns: {', '.join(header)}\n"
f"- dtypes: {', '.join(dtypes)}\n\n"
f"{rows_scope_line}\n"
f"{sample}"
)
return _truncate_text(body)
def _pdf_summary_text(reader: PdfReader) -> str:
pages = list(getattr(reader, "pages", []) or [])
chunks: list[str] = [f"# PDF Summary\n- pages: {len(pages)}\n"]
for idx, page in enumerate(pages, start=1):
txt = ""
try:
txt = str(page.extract_text() or "").strip()
except Exception:
txt = ""
if not txt:
txt = "(empty page text)"
chunks.append(f"## Page {idx}\n{txt}")
return _truncate_text("\n\n".join(chunks))
def _is_safe_zip_member_name(name: str) -> bool:
return is_safe_archive_member_name(name)
def _validate_excel_zip_payload(data: bytes) -> str | None:
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
infos = [x for x in zf.infolist() if not x.is_dir()]
if len(infos) > MAX_EXCEL_ZIP_FILE_COUNT:
return f"excel zip has too many entries ({len(infos)} > {MAX_EXCEL_ZIP_FILE_COUNT})"
total_uncompressed = 0
for info in infos:
if not _is_safe_zip_member_name(info.filename):
return f"excel zip contains unsafe path: {info.filename}"
fsize = int(info.file_size or 0)
total_uncompressed += fsize
if fsize > MAX_EXCEL_ZIP_ENTRY_BYTES:
return f"excel zip entry too large: {info.filename} ({fsize} > {MAX_EXCEL_ZIP_ENTRY_BYTES})"
if total_uncompressed > MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES:
return (
"excel zip total uncompressed size too large "
f"({total_uncompressed} > {MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES})"
)
return None
except Exception:
return "invalid excel zip payload"
def process_zip(zip_data: bytes, *, _depth: int = 0) -> list[dict[str, Any]]:
limits = _attachments_limits()
return process_archive(
archive_name="bundle.zip",
archive_data=zip_data,
process_member=lambda n, b, d: process_file_data(n, b, _zip_depth=d),
depth=_depth,
limits={
"max_depth": int(limits.get("archive_max_depth") or MAX_ARCHIVE_DEPTH),
"max_file_count": int(limits.get("archive_max_file_count") or MAX_ARCHIVE_FILE_COUNT),
"max_entry_bytes": int(limits.get("archive_max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES),
"max_total_uncompressed_bytes": int(
limits.get("archive_max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
),
},
)
def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[dict[str, Any]]:
ext = name.split(".")[-1].lower() if "." in name else ""
attachments: list[dict[str, Any]] = []
if ext in ("png", "jpg", "jpeg", "webp", "gif", "jfif", "pjpeg", "bmp", "tif", "tiff"):
if ext == "png":
mime = "image/png"
elif ext in ("jpg", "jpeg", "jfif", "pjpeg"):
mime = "image/jpeg"
elif ext == "webp":
mime = "image/webp"
elif ext == "gif":
mime = "image/gif"
elif ext == "bmp":
mime = "image/bmp"
elif ext in ("tif", "tiff"):
mime = "image/tiff"
width = None
height = None
try:
with Image.open(io.BytesIO(data)) as im:
width, height = im.size
except Exception:
pass
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime=mime, width=width, height=height)
attachments.append(
{
"type": "image_ref",
"name": meta.name,
"mime": meta.mime,
"attachment_id": meta.attachment_id,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
return attachments
sniff_mime = _sniff_image_mime(data)
if sniff_mime:
width = None
height = None
try:
with Image.open(io.BytesIO(data)) as im:
width, height = im.size
except Exception:
pass
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime=sniff_mime, width=width, height=height)
attachments.append(
{
"type": "image_ref",
"name": meta.name,
"mime": meta.mime,
"attachment_id": meta.attachment_id,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
return attachments
if ext in ("mp4", "mov", "mkv", "webm", "avi", "m4v"):
mime = "video/mp4"
if ext == "webm":
mime = "video/webm"
elif ext == "mov":
mime = "video/quicktime"
elif ext == "mkv":
mime = "video/x-matroska"
elif ext == "avi":
mime = "video/x-msvideo"
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime=mime)
probe_meta: dict[str, Any] = {}
try:
p = AttachmentAssetStore().get_local_path(str(meta.attachment_id))
if p is not None:
probe_meta = _ffprobe_video_meta(p)
except Exception:
probe_meta = {}
attachments.append(
{
"type": "video_ref",
"name": meta.name,
"mime": meta.mime,
"attachment_id": meta.attachment_id,
"bytes": meta.bytes,
**probe_meta,
}
)
attachments.append(
{
"type": "text",
"name": name,
"content": _truncate_text(
"".join(
[
"# Video Attachment\n",
f"- name: {meta.name}\n",
f"- mime: {meta.mime}\n",
f"- bytes: {meta.bytes}\n",
(
f"- duration_sec: {probe_meta.get('duration_sec')}\n"
if probe_meta.get("duration_sec") is not None
else ""
),
(
f"- size: {probe_meta.get('width')}x{probe_meta.get('height')}\n"
if probe_meta.get("width") and probe_meta.get("height")
else ""
),
(f"- fps: {probe_meta.get('fps')}\n" if probe_meta.get("fps") is not None else ""),
"- mode: video_ref\n",
"- query_tool: query_video_attachment\n",
"- note: use `attachment_id` from accompanying `video_ref` attachment.\n",
]
),
limit=MAX_TEXT_CHARS,
),
}
)
return attachments
if ext in ("xlsx", "xls", "csv"):
try:
if ext == "xlsx":
zip_err = _validate_excel_zip_payload(data)
if zip_err:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing excel/csv: {zip_err}")})
return attachments
limits = _attachments_limits()
rows_read = int(limits.get("rows_read") or DEFAULT_TABULAR_ROWS_READ)
max_columns = int(limits.get("columns") or DEFAULT_TABULAR_COLUMNS)
max_cell_chars = int(limits.get("cell_chars") or DEFAULT_TABULAR_CELL_CHARS)
max_excel_sheets = int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS)
large_table_preview_rows = int(
limits.get("large_table_preview_rows") or DEFAULT_LARGE_TABLE_PREVIEW_ROWS
)
tool_mode_enabled = bool(limits.get("tool_mode_enabled", DEFAULT_TABULAR_TOOL_MODE_ENABLED))
tool_mode_min_rows = int(limits.get("tool_mode_min_rows") or DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS)
tool_mode_max_bytes = int(limits.get("tool_mode_max_bytes") or DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES)
if ext == "csv":
df = pd.read_csv(io.BytesIO(data), nrows=rows_read)
else:
df = pd.read_excel(io.BytesIO(data), nrows=rows_read)
sampled = int(len(df.index)) >= rows_read
estimated_rows = 0
if ext == "csv":
estimated_rows = max(0, int(data.count(b"\n")) - 1)
else:
estimated_rows = int(len(df.index))
clipped_columns = int(len(df.columns)) > max_columns
if clipped_columns:
df = df.iloc[:, :max_columns]
df, clipped_cells = _clip_tabular_cells(df, max_cell_chars=max_cell_chars)
tool_mode = bool(
tool_mode_enabled
and int(len(data or b"")) <= tool_mode_max_bytes
and (estimated_rows >= tool_mode_min_rows or (ext in ("xlsx", "xls") and sampled))
)
attachments.append(
{
"type": "text",
"name": name,
"content": _dataframe_summary_text(
df,
sampled=sampled,
clipped_columns=clipped_columns,
clipped_cells=clipped_cells,
preview_rows=(large_table_preview_rows if tool_mode else EXCEL_PREVIEW_ROWS),
)
+ (
(
f"\n\n# Large Table Mode\n- estimated_rows: {estimated_rows}\n- query_tool: query_tabular_attachment\n"
"- note: use the `table_id` from the accompanying `tabular_ref` attachment to query full data."
)
if tool_mode
else ""
),
}
)
if tool_mode:
workbook_sheets: dict[str, pd.DataFrame]
clipped_sheets = False
if ext == "csv":
full_df = pd.read_csv(io.BytesIO(data), dtype=str)
if int(len(full_df.columns)) > max_columns:
full_df = full_df.iloc[:, :max_columns]
full_df, _ = _clip_tabular_cells(full_df, max_cell_chars=max_cell_chars)
workbook_sheets = {"Sheet1": full_df}
else:
excel_obj = pd.read_excel(io.BytesIO(data), dtype=str, sheet_name=None)
workbook_sheets = {}
sheet_items = list((excel_obj or {}).items())
if len(sheet_items) > max_excel_sheets:
clipped_sheets = True
sheet_items = sheet_items[:max_excel_sheets]
for sheet_name, sheet_df in sheet_items:
sdf = sheet_df
if int(len(sdf.columns)) > max_columns:
sdf = sdf.iloc[:, :max_columns]
sdf, _ = _clip_tabular_cells(sdf, max_cell_chars=max_cell_chars)
workbook_sheets[str(sheet_name or f"Sheet{len(workbook_sheets)+1}")] = sdf
mime = "text/csv" if ext == "csv" else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime=mime)
table_meta = (
save_dataframe(attachment_id=str(meta.attachment_id), name=name, df=workbook_sheets.get("Sheet1", pd.DataFrame()))
if ext == "csv"
else save_workbook(attachment_id=str(meta.attachment_id), name=name, sheets=workbook_sheets)
)
attachments.append(
{
"type": "tabular_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"table_id": str(table_meta.get("table_id") or ""),
"rows": int(table_meta.get("rows") or 0),
"cols": int(table_meta.get("cols") or 0),
"columns": list(table_meta.get("columns") or []),
"sheets": list(table_meta.get("sheets") or []),
}
)
if clipped_sheets:
attachments.append(
{
"type": "text",
"name": f"{name}.sheet-limit",
"content": (
f"Excel workbook contains too many sheets; only the first {max_excel_sheets} sheets were stored "
"for tool-mode querying. Adjust max_excel_sheets if full coverage is required."
),
}
)
except Exception as e:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing excel/csv: {e}")})
elif ext == "docx":
try:
doc = Document(io.BytesIO(data))
text = "\n".join([p.text for p in doc.paragraphs])
limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(
data,
filename=name,
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind="docx",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind="docx",
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": "docx",
}
)
except Exception as e:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing docx: {e}")})
elif ext == "pdf":
try:
reader = PdfReader(io.BytesIO(data))
text = _pdf_summary_text(reader)
limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime="application/pdf")
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind="pdf",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind="pdf",
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": "pdf",
}
)
except Exception as e:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing pdf: {e}")})
elif ext in ("txt", "py", "js", "html", "css", "md", "json", "yaml", "yml", "sh", "log"):
text = _decode_text_bytes(data)
if ext == "html":
text = _clean_html_to_text(text)
limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime="text/plain")
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind=ext or "text",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind=str(text_meta.get("source_kind") or ext or "text"),
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": str(text_meta.get("source_kind") or ext or "text"),
}
)
elif detect_archive_kind(name):
limits = _attachments_limits()
attachments.extend(
process_archive(
archive_name=name,
archive_data=data,
process_member=lambda n, b, d: process_file_data(n, b, _zip_depth=d),
depth=_zip_depth,
limits={
"max_depth": int(limits.get("archive_max_depth") or MAX_ARCHIVE_DEPTH),
"max_file_count": int(limits.get("archive_max_file_count") or MAX_ARCHIVE_FILE_COUNT),
"max_entry_bytes": int(limits.get("archive_max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES),
"max_total_uncompressed_bytes": int(
limits.get("archive_max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
),
},
)
)
return attachments
__all__ = ["process_zip", "process_file_data", "clear_attachment_limits_cache"]

View file

@ -0,0 +1,66 @@
from __future__ import annotations
import json
from typing import Any
from svc.persistence.sqlite_store import ChatMessage, SqliteStore
def export_session_json(store: SqliteStore, session_id: str, message_limit: int = 50_000) -> str:
title = ""
s = store.get_session(session_id)
if s:
title = s.title
msgs = store.get_messages(session_id=session_id, limit=message_limit)
payload: dict[str, Any] = {
"session_id": session_id,
"title": title,
"messages": [_message_to_dict(m) for m in msgs],
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def export_session_markdown(store: SqliteStore, session_id: str, message_limit: int = 50_000) -> str:
s = store.get_session(session_id)
title = (s.title if s else "") or session_id[:12]
lines = [f"# {title}", "", f"Session: `{session_id}`", ""]
for m in store.get_messages(session_id=session_id, limit=message_limit):
lines.extend(_message_to_markdown_lines(m))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _message_to_dict(m: ChatMessage) -> dict[str, Any]:
return {
"id": m.id,
"role": m.role,
"content": m.content,
"tool_calls": m.tool_calls,
"attachments": m.attachments,
"timestamp": m.timestamp,
}
def _message_to_markdown_lines(m: ChatMessage) -> list[str]:
role = m.role.upper()
ts = m.timestamp or ""
head = f"## {role} · `{m.id}` · {ts}"
body = m.content or ""
if m.role == "tool" and m.tool_calls:
head = f"## TOOL · `{m.id}` · {ts}"
lines = [head, ""]
if body.strip():
lines.append(body)
lines.append("")
if m.tool_calls and m.role != "tool":
lines.append("```json")
lines.append(m.tool_calls)
lines.append("```")
elif m.tool_calls and m.role == "tool":
lines.append(f"_meta_: `{m.tool_calls}`")
if m.attachments:
lines.append("_attachments_: see JSON export for full data.")
return lines
__all__ = ["export_session_json", "export_session_markdown"]

View file

@ -0,0 +1,732 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any
import pandas as pd
from svc.config.paths import PROJECT_ROOT
from svc.config.paths import attachments_dir
def _tabular_root() -> Path:
p = (attachments_dir() / "tabular").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _table_id(attachment_id: str, name: str) -> str:
raw = f"{attachment_id}:{name}".encode("utf-8", errors="ignore")
return hashlib.sha256(raw).hexdigest()
def _db_path(table_id: str) -> Path:
return _tabular_root() / f"{table_id}.sqlite"
def _meta_path(table_id: str) -> Path:
return _tabular_root() / f"{table_id}.meta.json"
def _is_valid_table_id(table_id: str) -> bool:
tid = str(table_id or "").strip().lower()
return bool(re.fullmatch(r"[0-9a-f]{64}", tid))
def _normalize_columns(raw_cols: list[Any]) -> tuple[list[str], list[dict[str, Any]]]:
out: list[str] = []
mapping: list[dict[str, Any]] = []
seen: dict[str, int] = {}
for idx, raw in enumerate(raw_cols):
original = "" if raw is None else str(raw)
base = original.strip()
if not base or base.lower().startswith("unnamed:"):
base = f"col_{idx + 1}"
base = re.sub(r"\s+", "_", base)
base = re.sub(r"\W+", "_", base, flags=re.UNICODE).strip("_")
if not base:
base = f"col_{idx + 1}"
n = int(seen.get(base, 0)) + 1
seen[base] = n
name = base if n == 1 else f"{base}__{n}"
out.append(name)
mapping.append({"index": idx, "original": original, "normalized": name})
return out, mapping
def _prepare_writable_df(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str], list[dict[str, Any]]]:
raw_cols = [x for x in list(df.columns)]
cols, col_map = _normalize_columns(raw_cols)
writable = df.copy()
writable.columns = cols
for c in cols:
writable[c] = writable[c].map(lambda x: "" if x is None else str(x))
return writable, cols, col_map
def _safe_sheet_table_name(sheet_name: str, idx: int) -> str:
base = re.sub(r"[^0-9a-zA-Z_]+", "_", str(sheet_name or "").strip()).strip("_").lower()
if not base:
base = f"sheet_{idx + 1}"
return f"sheet_{idx + 1}_{base}"
def _mcp_sqlite_command() -> list[str]:
raw = str(os.getenv("AIA_MCP_SQLITE_COMMAND") or "").strip()
if not raw:
return []
return [x for x in raw.split(" ") if x]
def _tabular_use_mcp() -> bool:
raw = str(os.getenv("AIA_TABULAR_USE_MCP") or "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _tabular_sql_timeout_ms() -> int:
def _clamp_timeout(raw: Any, default: int = 8_000) -> int:
try:
n = int(raw)
except Exception:
return default
return max(100, min(n, 120_000))
raw = str(os.getenv("AIA_TABULAR_SQL_TIMEOUT_MS") or "").strip()
if raw:
return _clamp_timeout(raw)
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw.json").resolve()
if not cfg_path.is_absolute():
cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve()
try:
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
except Exception:
return 8_000
tabular_cfg = (
(((obj or {}).get("plugins") or {}).get("entries") or {}).get("memory-wiki", {}).get("auto", {}).get("attachments", {}).get("tabular", {}) or {}
)
if not isinstance(tabular_cfg, dict):
return 8_000
return _clamp_timeout(tabular_cfg.get("sql_timeout_ms"), 8_000)
def _extract_rows_from_mcp_result(res: dict[str, Any]) -> list[dict[str, Any]] | None:
if not isinstance(res, dict) or not bool(res.get("ok")):
return None
data = res.get("data") if isinstance(res.get("data"), dict) else res.get("result")
if isinstance(data, dict):
direct_rows = data.get("rows")
if isinstance(direct_rows, list):
out: list[dict[str, Any]] = []
for r in direct_rows:
if isinstance(r, dict):
out.append({str(k): r[k] for k in r.keys()})
return out
content = data.get("content")
if isinstance(content, list):
for it in content:
if not isinstance(it, dict):
continue
txt = str(it.get("text") or "").strip()
if not txt:
continue
try:
obj = json.loads(txt)
except Exception:
continue
if isinstance(obj, dict) and isinstance(obj.get("rows"), list):
rows = obj.get("rows")
out: list[dict[str, Any]] = []
for r in rows:
if isinstance(r, dict):
out.append({str(k): r[k] for k in r.keys()})
return out
return None
def _run_select_via_mcp(*, db_path: Path, sql: str, params: list[Any]) -> list[dict[str, Any]] | None:
if not _tabular_use_mcp():
return None
cmd = _mcp_sqlite_command()
if not cmd:
return None
def _call_once(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
try:
p = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
except Exception:
return {"ok": False}
try:
if p.stdin is None or p.stdout is None:
return {"ok": False}
init_req = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "oclaw-tabular", "version": "0.1.0"},
},
}
p.stdin.write(json.dumps(init_req, ensure_ascii=False) + "\n")
p.stdin.flush()
_ = p.stdout.readline()
p.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, ensure_ascii=False) + "\n")
p.stdin.flush()
call_req = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": str(tool_name), "arguments": arguments},
}
p.stdin.write(json.dumps(call_req, ensure_ascii=False) + "\n")
p.stdin.flush()
line = p.stdout.readline()
if not line:
return {"ok": False}
obj = json.loads(line)
if not isinstance(obj, dict):
return {"ok": False}
if isinstance(obj.get("error"), dict):
return {"ok": False}
return {"ok": True, "result": obj.get("result")}
except Exception:
return {"ok": False}
finally:
try:
p.terminate()
except Exception:
pass
# Probe several common sqlite MCP tool names/argument shapes.
attempts: list[tuple[str, dict[str, Any]]] = [
("query", {"database": str(db_path), "sql": sql, "params": params}),
("query", {"db_path": str(db_path), "sql": sql, "params": params}),
("execute_sql", {"database": str(db_path), "sql": sql, "params": params}),
("read_query", {"database": str(db_path), "query": sql, "params": params}),
("sqlite_query", {"database": str(db_path), "sql": sql, "params": params}),
]
for tool_name, args in attempts:
res = _call_once(tool_name=tool_name, arguments=args)
rows = _extract_rows_from_mcp_result(res if isinstance(res, dict) else {})
if rows is not None:
return rows
return None
def _run_select_sql(
*,
db_path: Path,
sql: str,
params: list[Any],
timeout_ms: int | None = None,
) -> tuple[list[dict[str, Any]], str]:
rows = _run_select_via_mcp(db_path=db_path, sql=sql, params=params)
if rows is not None:
return rows, "mcp_sqlite"
out_rows: list[dict[str, Any]] = []
timeout_ms_eff = max(100, int(timeout_ms or _tabular_sql_timeout_ms()))
deadline = time.perf_counter() + (float(timeout_ms_eff) / 1000.0)
timed_out = {"hit": False}
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
def _progress_cb() -> int:
if time.perf_counter() > deadline:
timed_out["hit"] = True
return 1
return 0
conn.set_progress_handler(_progress_cb, 1000)
try:
for r in conn.execute(sql, params).fetchall():
out_rows.append({k: r[k] for k in r.keys()})
except sqlite3.OperationalError as exc:
if timed_out["hit"] and "interrupted" in str(exc).lower():
raise TimeoutError(f"tabular_sql_timeout_ms={timeout_ms_eff}") from exc
raise
finally:
conn.set_progress_handler(None, 0)
return out_rows, "builtin_sqlite"
def save_dataframe(*, attachment_id: str, name: str, df: pd.DataFrame) -> dict[str, Any]:
return save_workbook(attachment_id=attachment_id, name=name, sheets={"Sheet1": df})
def save_workbook(*, attachment_id: str, name: str, sheets: dict[str, pd.DataFrame]) -> dict[str, Any]:
tid = _table_id(attachment_id=attachment_id, name=name)
db = _db_path(tid)
meta = _meta_path(tid)
sheet_rows: list[dict[str, Any]] = []
default_table = "rows_data"
default_columns: list[str] = []
default_rows = 0
default_cols = 0
with sqlite3.connect(str(db)) as conn:
for idx, (sheet_name, df) in enumerate((sheets or {}).items()):
writable, cols, col_map = _prepare_writable_df(df)
table_name = _safe_sheet_table_name(str(sheet_name or f"Sheet{idx + 1}"), idx)
writable.to_sql(table_name, conn, if_exists="replace", index=False)
if idx == 0:
# backward compatibility: old queries use rows_data
writable.to_sql("rows_data", conn, if_exists="replace", index=False)
default_table = table_name
default_columns = list(cols)
default_rows = int(len(writable.index))
default_cols = int(len(cols))
sheet_rows.append(
{
"sheet_name": str(sheet_name or f"Sheet{idx + 1}"),
"table_name": table_name,
"rows": int(len(writable.index)),
"cols": int(len(cols)),
"columns": list(cols),
"column_map": col_map,
}
)
payload = {
"table_id": tid,
"attachment_id": str(attachment_id or ""),
"name": str(name or ""),
"rows": int(default_rows),
"cols": int(default_cols),
"columns": default_columns,
"default_table": default_table,
"sheets": sheet_rows,
"db_path": str(db),
}
meta.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return payload
def get_table_meta(table_id: str) -> dict[str, Any] | None:
tid = str(table_id or "").strip()
if not _is_valid_table_id(tid):
return None
p = _meta_path(tid)
if not p.exists():
return None
try:
obj = json.loads(p.read_text(encoding="utf-8"))
return obj if isinstance(obj, dict) else None
except Exception:
return None
def _resolve_sheet_table(meta: dict[str, Any], sheet: str | None) -> str:
default_table = str(meta.get("default_table") or "rows_data")
if not sheet:
return default_table
target = str(sheet).strip()
if not target:
return default_table
sheets = meta.get("sheets") if isinstance(meta.get("sheets"), list) else []
for row in sheets:
if not isinstance(row, dict):
continue
if str(row.get("sheet_name") or "") == target or str(row.get("table_name") or "") == target:
return str(row.get("table_name") or default_table)
return ""
def _columns_for_sheet(meta: dict[str, Any], table_name: str) -> list[str]:
sheets = meta.get("sheets") if isinstance(meta.get("sheets"), list) else []
for row in sheets:
if not isinstance(row, dict):
continue
if str(row.get("table_name") or "") == str(table_name):
cols = row.get("columns")
if isinstance(cols, list):
return [str(x) for x in cols]
cols = meta.get("columns")
if isinstance(cols, list):
return [str(x) for x in cols]
return []
def _sheet_hint_payload(meta: dict[str, Any], *, selected_sheet: str | None, table_name: str) -> dict[str, Any] | None:
sheets = meta.get("sheets") if isinstance(meta.get("sheets"), list) else []
valid_rows = [x for x in sheets if isinstance(x, dict)]
if len(valid_rows) <= 1:
return None
if str(selected_sheet or "").strip():
return None
names = [str((x or {}).get("sheet_name") or "") for x in valid_rows]
names = [x for x in names if x]
if len(names) <= 1:
return None
default_sheet_name = ""
for row in valid_rows:
if str(row.get("table_name") or "") == str(table_name):
default_sheet_name = str(row.get("sheet_name") or "")
break
return {
"available_sheets": names[:20],
"default_sheet": default_sheet_name,
"note": "Workbook contains multiple sheets; pass `sheet` to target a specific sheet.",
}
def query_table(
*,
table_id: str,
columns: list[str] | None = None,
limit: int = 50,
offset: int = 0,
where_contains: dict[str, str] | None = None,
sheet: str | None = None,
) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "table_store_missing"}
table_name = _resolve_sheet_table(meta, sheet)
if not table_name:
return {"ok": False, "error": "sheet_not_found"}
known_cols = _columns_for_sheet(meta, table_name)
use_cols = [c for c in (columns or known_cols) if c in known_cols]
if not use_cols:
use_cols = known_cols[: min(10, len(known_cols))]
lim = max(1, min(int(limit or 50), 200))
off = max(0, int(offset or 0))
select_sql = ", ".join([f'"{c}"' for c in use_cols]) if use_cols else "*"
where_sql = ""
params: list[Any] = []
wc = where_contains if isinstance(where_contains, dict) else {}
wc_col = str(wc.get("column") or "").strip()
wc_kw = str(wc.get("keyword") or "").strip()
if wc_col and wc_kw and wc_col in known_cols:
where_sql = f' WHERE "{wc_col}" LIKE ? '
params.append(f"%{wc_kw}%")
sql = f'SELECT {select_sql} FROM "{table_name}"{where_sql} LIMIT ? OFFSET ?'
params.extend([lim, off])
out_rows: list[dict[str, Any]] = []
total = 0
out_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
with sqlite3.connect(str(db)) as conn:
total = int(conn.execute(f'SELECT COUNT(1) AS n FROM "{table_name}"').fetchone()[0] or 0)
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
return {
"ok": True,
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
"sheet": str(sheet or ""),
"table_name": table_name,
"rows_total": total,
"columns": use_cols,
"rows": out_rows,
"limit": lim,
"offset": off,
"engine": engine,
**({"sheet_hint": hint} if isinstance(hint, dict) else {}),
}
def aggregate_table(
*,
table_id: str,
metric: str,
target_column: str | None = None,
group_by: str | None = None,
where_contains: dict[str, str] | None = None,
top_n: int = 20,
sheet: str | None = None,
) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "table_store_missing"}
table_name = _resolve_sheet_table(meta, sheet)
if not table_name:
return {"ok": False, "error": "sheet_not_found"}
known_cols = _columns_for_sheet(meta, table_name)
mt = str(metric or "").strip().lower()
if mt not in {"count", "sum", "avg"}:
return {"ok": False, "error": "invalid_metric"}
grp = str(group_by or "").strip()
if grp and grp not in known_cols:
return {"ok": False, "error": "invalid_group_by"}
tgt = str(target_column or "").strip()
if mt in {"sum", "avg"} and tgt not in known_cols:
return {"ok": False, "error": "target_column_required"}
where_sql = ""
params: list[Any] = []
wc = where_contains if isinstance(where_contains, dict) else {}
wc_col = str(wc.get("column") or "").strip()
wc_kw = str(wc.get("keyword") or "").strip()
if wc_col and wc_kw and wc_col in known_cols:
where_sql = f' WHERE "{wc_col}" LIKE ? '
params.append(f"%{wc_kw}%")
lim = max(1, min(int(top_n or 20), 200))
if mt == "count":
metric_sql = "COUNT(1)"
else:
metric_sql = f'{"SUM" if mt == "sum" else "AVG"}(CAST(NULLIF("{tgt}", \'\') AS REAL))'
if grp:
sql = (
f'SELECT "{grp}" AS group_key, {metric_sql} AS metric_value '
f'FROM "{table_name}"{where_sql} GROUP BY "{grp}" '
"ORDER BY metric_value DESC LIMIT ?"
)
params.append(lim)
raw_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
rows: list[dict[str, Any]] = []
for r in raw_rows:
rows.append(
{
"group": r.get("group_key"),
"value": float(r.get("metric_value") or 0.0),
}
)
out = {
"ok": True,
"table_id": str(table_id),
"sheet": str(sheet or ""),
"table_name": table_name,
"metric": mt,
"target_column": tgt if mt in {"sum", "avg"} else None,
"group_by": grp,
"rows": rows,
"top_n": lim,
"engine": engine,
}
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
sql = f'SELECT {metric_sql} AS metric_value FROM "{table_name}"{where_sql} '
raw_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
row = raw_rows[0] if raw_rows else {}
out = {
"ok": True,
"table_id": str(table_id),
"sheet": str(sheet or ""),
"table_name": table_name,
"metric": mt,
"target_column": tgt if mt in {"sum", "avg"} else None,
"group_by": "",
"value": float((row.get("metric_value") if isinstance(row, dict) else 0.0) or 0.0),
"engine": engine,
}
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
def run_table_sql(
*,
table_id: str,
sql: str,
limit: int = 200,
sheet: str | None = None,
) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "table_store_missing"}
table_name = _resolve_sheet_table(meta, sheet)
if not table_name:
return {"ok": False, "error": "sheet_not_found"}
raw = str(sql or "").strip()
if not raw:
return {"ok": False, "error": "sql_required"}
lowered = raw.lower()
deny = ("insert ", "update ", "delete ", "drop ", "alter ", "attach ", "detach ", "pragma ", "create ")
if any(k in lowered for k in deny):
return {"ok": False, "error": "sql_not_readonly"}
if ";" in raw:
return {"ok": False, "error": "sql_multiple_statements_forbidden"}
if not (lowered.startswith("select ") or lowered.startswith("with ")):
return {"ok": False, "error": "sql_must_start_with_select_or_with"}
lim = max(1, min(int(limit or 200), 500))
auto_limited = " limit " not in lowered
sql_targeted = re.sub(r"(?i)\brows_data\b", f'"{table_name}"', raw)
safe_sql = sql_targeted if not auto_limited else f"{sql_targeted}\nLIMIT {lim}"
timeout_ms = _tabular_sql_timeout_ms()
try:
rows, engine = _run_select_sql(db_path=db, sql=safe_sql, params=[], timeout_ms=timeout_ms)
except TimeoutError:
return {
"ok": False,
"error": "sql_timeout",
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
"sheet": str(sheet or ""),
"table_name": table_name,
"input_sql": raw,
"executed_sql": safe_sql,
"sql_guard": {
"readonly_enforced": True,
"multi_statement_forbidden": True,
"auto_limit_applied": bool(auto_limited),
"result_row_cap": lim,
"timeout_ms": int(timeout_ms),
"timeout_hit": True,
},
}
out_rows = rows[:lim]
out = {
"ok": True,
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
"sheet": str(sheet or ""),
"table_name": table_name,
"input_sql": raw,
"executed_sql": safe_sql,
"sql_guard": {
"readonly_enforced": True,
"multi_statement_forbidden": True,
"auto_limit_applied": bool(auto_limited),
"result_row_cap": lim,
"timeout_ms": int(timeout_ms),
"timeout_hit": False,
},
"rows": out_rows,
"rows_returned": int(len(out_rows)),
"limit": lim,
"engine": engine,
}
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
def analyze_table_full_scan(
*,
table_id: str,
columns: list[str] | None = None,
sheet: str | None = None,
top_values_limit: int = 3,
) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "table_store_missing"}
table_name = _resolve_sheet_table(meta, sheet)
if not table_name:
return {"ok": False, "error": "sheet_not_found"}
known_cols = _columns_for_sheet(meta, table_name)
if not known_cols:
return {"ok": False, "error": "table_columns_missing"}
use_cols = [c for c in (columns or known_cols) if c in known_cols]
if not use_cols:
return {"ok": False, "error": "columns_not_found"}
top_lim = max(0, min(int(top_values_limit or 0), 10))
started = time.perf_counter()
with sqlite3.connect(str(db)) as conn:
total_rows = int(conn.execute(f'SELECT COUNT(1) FROM "{table_name}"').fetchone()[0] or 0)
col_stats: list[dict[str, Any]] = []
for col in use_cols:
row = conn.execute(
(
f'SELECT '
f'SUM(CASE WHEN NULLIF(TRIM("{col}"), \'\') IS NOT NULL THEN 1 ELSE 0 END) AS non_empty_rows, '
f'COUNT(DISTINCT NULLIF(TRIM("{col}"), \'\')) AS distinct_values '
f'FROM "{table_name}"'
)
).fetchone()
non_empty_rows = int((row[0] if row else 0) or 0)
distinct_values = int((row[1] if row else 0) or 0)
item: dict[str, Any] = {
"column": col,
"non_empty_rows": non_empty_rows,
"empty_rows": max(0, int(total_rows - non_empty_rows)),
"distinct_values": distinct_values,
"coverage_ratio": (float(non_empty_rows) / float(total_rows)) if total_rows > 0 else 0.0,
}
if top_lim > 0:
tops = conn.execute(
(
f'SELECT "{col}" AS val, COUNT(1) AS n '
f'FROM "{table_name}" '
f'WHERE NULLIF(TRIM("{col}"), \'\') IS NOT NULL '
f'GROUP BY "{col}" ORDER BY n DESC LIMIT ?'
),
[top_lim],
).fetchall()
item["top_values"] = [{"value": str(r[0]), "count": int(r[1] or 0)} for r in tops]
col_stats.append(item)
elapsed_ms = int((time.perf_counter() - started) * 1000)
out = {
"ok": True,
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
"sheet": str(sheet or ""),
"table_name": table_name,
"rows_scanned": int(total_rows),
"columns_analyzed": list(use_cols),
"column_stats": col_stats,
"scan_audit": {
"full_scan": True,
"rows_scanned": int(total_rows),
"columns_count": int(len(use_cols)),
"elapsed_ms": int(elapsed_ms),
},
"engine": "builtin_sqlite_fullscan",
}
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
__all__ = [
"save_dataframe",
"save_workbook",
"get_table_meta",
"query_table",
"aggregate_table",
"run_table_sql",
"analyze_table_full_scan",
]

View file

@ -0,0 +1,197 @@
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
from svc.config.paths import attachments_dir
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000
DEFAULT_TEXT_CHUNK_SIZE = 1_600
DEFAULT_TEXT_CHUNK_OVERLAP = 200
def _text_root() -> Path:
p = (attachments_dir() / "textual").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _text_id(attachment_id: str, name: str) -> str:
raw = f"{attachment_id}:{name}".encode("utf-8", errors="ignore")
return hashlib.sha256(raw).hexdigest()
def _db_path(text_id: str) -> Path:
return _text_root() / f"{text_id}.sqlite"
def _meta_path(text_id: str) -> Path:
return _text_root() / f"{text_id}.meta.json"
def _is_valid_text_id(text_id: str) -> bool:
tid = str(text_id or "").strip().lower()
return bool(re.fullmatch(r"[0-9a-f]{64}", tid))
def _chunk_text(*, text: str, chunk_size: int, overlap: int) -> list[dict[str, Any]]:
body = str(text or "")
if not body:
return []
size = max(200, min(int(chunk_size or DEFAULT_TEXT_CHUNK_SIZE), 8_000))
ov = max(0, min(int(overlap or DEFAULT_TEXT_CHUNK_OVERLAP), size - 1))
chunks: list[dict[str, Any]] = []
i = 0
idx = 0
n = len(body)
while i < n:
end = min(n, i + size)
chunk = body[i:end]
chunks.append({"chunk_index": idx, "start_char": i, "end_char": end, "content": chunk})
if end >= n:
break
i = end - ov
idx += 1
return chunks
def save_text_document(
*,
attachment_id: str,
name: str,
text: str,
source_kind: str,
chunk_size: int = DEFAULT_TEXT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_TEXT_CHUNK_OVERLAP,
) -> dict[str, Any]:
tid = _text_id(attachment_id=attachment_id, name=name)
db = _db_path(tid)
meta = _meta_path(tid)
chunks = _chunk_text(text=str(text or ""), chunk_size=chunk_size, overlap=chunk_overlap)
with sqlite3.connect(str(db)) as conn:
conn.execute("DROP TABLE IF EXISTS text_chunks")
conn.execute(
(
"CREATE TABLE text_chunks ("
"chunk_index INTEGER NOT NULL, "
"start_char INTEGER NOT NULL, "
"end_char INTEGER NOT NULL, "
"content TEXT NOT NULL)"
)
)
for c in chunks:
conn.execute(
"INSERT INTO text_chunks(chunk_index,start_char,end_char,content) VALUES(?,?,?,?)",
[
int(c.get("chunk_index") or 0),
int(c.get("start_char") or 0),
int(c.get("end_char") or 0),
str(c.get("content") or ""),
],
)
conn.commit()
payload = {
"text_id": tid,
"attachment_id": str(attachment_id or ""),
"name": str(name or ""),
"source_kind": str(source_kind or "text"),
"chars": int(len(str(text or ""))),
"chunks": int(len(chunks)),
"chunk_size": int(chunk_size),
"chunk_overlap": int(chunk_overlap),
"db_path": str(db),
}
meta.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return payload
def get_text_meta(text_id: str) -> dict[str, Any] | None:
tid = str(text_id or "").strip()
if not _is_valid_text_id(tid):
return None
p = _meta_path(tid)
if not p.exists():
return None
try:
obj = json.loads(p.read_text(encoding="utf-8"))
return obj if isinstance(obj, dict) else None
except Exception:
return None
def query_text_document(
*,
text_id: str,
query: str | None = None,
top_k: int = 5,
offset: int = 0,
) -> dict[str, Any]:
tid = str(text_id or "").strip()
if not _is_valid_text_id(tid):
return {"ok": False, "error": "text_id_invalid_format"}
meta = get_text_meta(tid)
if not isinstance(meta, dict):
return {"ok": False, "error": "text_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "text_store_missing"}
lim = max(1, min(int(top_k or 5), 50))
off = max(0, int(offset or 0))
q = str(query or "").strip()
rows: list[dict[str, Any]] = []
with sqlite3.connect(str(db)) as conn:
conn.row_factory = sqlite3.Row
total = int(conn.execute("SELECT COUNT(1) AS n FROM text_chunks").fetchone()[0] or 0)
if q:
q_like = f"%{q.lower()}%"
sql = (
"SELECT chunk_index,start_char,end_char,content, "
"(LENGTH(LOWER(content)) - LENGTH(REPLACE(LOWER(content), LOWER(?), ''))) / MAX(1, LENGTH(?)) AS score "
"FROM text_chunks WHERE LOWER(content) LIKE ? "
"ORDER BY score DESC, chunk_index ASC LIMIT ? OFFSET ?"
)
out = conn.execute(sql, [q, q, q_like, lim, off]).fetchall()
else:
out = conn.execute(
"SELECT chunk_index,start_char,end_char,content, 0 AS score FROM text_chunks ORDER BY chunk_index ASC LIMIT ? OFFSET ?",
[lim, off],
).fetchall()
for r in out:
rows.append(
{
"chunk_index": int(r["chunk_index"] or 0),
"start_char": int(r["start_char"] or 0),
"end_char": int(r["end_char"] or 0),
"content": str(r["content"] or ""),
"score": float(r["score"] or 0.0),
}
)
return {
"ok": True,
"text_id": tid,
"name": str(meta.get("name") or ""),
"source_kind": str(meta.get("source_kind") or "text"),
"chars": int(meta.get("chars") or 0),
"chunks_total": int(meta.get("chunks") or 0),
"rows_total": int(total),
"query": q,
"rows": rows,
"top_k": lim,
"offset": off,
}
__all__ = [
"DEFAULT_TEXT_INLINE_MAX_CHARS",
"DEFAULT_TEXT_CHUNK_SIZE",
"DEFAULT_TEXT_CHUNK_OVERLAP",
"save_text_document",
"get_text_meta",
"query_text_document",
]

View file

@ -0,0 +1,87 @@
"""Locate and optionally launch the ``vendor/cc-mini`` git submodule.
Upstream lives at https://github.com/e10nMa2k/cc-mini — keep it as a submodule
rather than copying sources into this tree. At the time of integration the
submodule repository did not ship a root ``LICENSE`` file; treat compliance as
upstream-owned until one is published.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from svc.config.paths import PROJECT_ROOT
def cc_mini_root() -> Path:
"""Path to the ``cc-mini`` checkout (``<repo>/vendor/cc-mini``)."""
return (PROJECT_ROOT / "vendor" / "cc-mini").resolve()
def cc_mini_src_dir() -> Path:
"""Directory that must be on ``PYTHONPATH`` for ``python -m tui.app``."""
return (cc_mini_root() / "src").resolve()
def cc_mini_available() -> bool:
"""True when the submodule tree looks present (``src/core/engine.py``)."""
return (cc_mini_src_dir() / "core" / "engine.py").is_file()
def cc_mini_pythonpath_env(base: dict[str, str] | None = None) -> dict[str, str]:
"""Return env with ``vendor/cc-mini/src`` prepended to ``PYTHONPATH``."""
env = dict(base or os.environ)
src = str(cc_mini_src_dir())
prev = (env.get("PYTHONPATH") or "").strip()
env["PYTHONPATH"] = src + (os.pathsep + prev if prev else "")
return env
def prepend_cc_mini_src_to_sys_path() -> bool:
"""Insert ``vendor/cc-mini/src`` at the front of ``sys.path`` if available.
Prefer subprocess + :func:`cc_mini_pythonpath_env` when isolation matters;
importing ``core`` / ``features`` from cc-mini can clash with similarly
named packages elsewhere on ``sys.path``.
"""
src = cc_mini_src_dir()
if not cc_mini_available():
return False
s = str(src)
if s not in sys.path:
sys.path.insert(0, s)
return True
def run_cc_mini_cli(
argv: list[str] | None = None,
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
**kwargs: Any,
) -> subprocess.CompletedProcess[str]:
"""Run cc-mini's interactive entry (``python -m tui.app``) like the ``cc-mini`` console script.
Requires ``cc_mini_available()``; raises ``FileNotFoundError`` if the checkout is missing.
"""
root = cc_mini_root()
if not cc_mini_available():
raise FileNotFoundError(
f"cc-mini not found under {root}; run: git submodule update --init vendor/cc-mini"
)
merged = cc_mini_pythonpath_env(env if env is not None else os.environ)
cmd = [sys.executable, "-m", "tui.app", *(argv or [])]
return subprocess.run(
cmd,
cwd=str(cwd or root),
env=merged,
check=False,
text=True,
encoding="utf-8",
errors="replace",
**kwargs,
)

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from svc.persistence.sqlite_store import SqliteStore
class WeComClient:
"""WeCom AI bot (websocket / bot_api only)."""
def __init__(self, store: SqliteStore):
self.store = store
def _bot_cfg(self) -> tuple[str, str]:
bot_id = str(self.store.get_setting("wecom_bot_id") or "").strip()
bot_secret = str(self.store.get_secret("wecom_bot_secret") or self.store.get_setting("wecom_bot_secret") or "").strip()
if bot_id and bot_secret:
return bot_id, bot_secret
if bot_id:
row = self.store.find_user_by_channel_account(channel="wecom", account_id=bot_id)
if row:
tid = str(row["tenant_id"])
uid = str(row["user_id"])
sk = f"wecom:bot_secret:{tid}:{uid}:{bot_id}"
sec = str(self.store.get_secret(sk) or "").strip()
if sec:
return bot_id, sec
try:
tenants = self.store.list_tenants(limit=1)
tenant_id = str((tenants[0] or {}).get("id") or "") if tenants else ""
if not tenant_id:
return bot_id, bot_secret
admin = self.store.get_user_by_username(tenant_id=tenant_id, username="administrator")
user_id = str((admin or {}).get("id") or "")
if not user_id:
return bot_id, bot_secret
accounts = self.store.list_user_channel_accounts(
tenant_id=tenant_id, user_id=user_id, channel="wecom", include_inactive=False
)
if not accounts:
return bot_id, bot_secret
aid = str(accounts[0].get("account_id") or "").strip()
if not aid:
return bot_id, bot_secret
scoped_key = f"wecom:bot_secret:{tenant_id}:{user_id}:{aid}"
sec = str(self.store.get_secret(scoped_key) or "").strip()
if aid and sec:
return aid, sec
except Exception:
return bot_id, bot_secret
return bot_id, bot_secret
def mode_for_account(self, account_id: str) -> str: # noqa: ARG002
return "bot_api"
def mode(self) -> str:
return "bot_api"
def get_bot_credentials(self) -> tuple[str, str]:
bot_id, bot_secret = self._bot_cfg()
if not bot_id or not bot_secret:
raise RuntimeError("missing wecom bot config: wecom_bot_id / wecom_bot_secret")
return bot_id, bot_secret
__all__ = ["WeComClient"]

27
svc/llm/MIT_LICENSE.txt Normal file
View file

@ -0,0 +1,27 @@
MIT License
Copyright (c) 2025 Peter Steinberger (Oclaw)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
The Python modules `tool_call_id.py` and `replay_policy.py` in this directory
implement replay / tool-call-id handling inspired by Oclaw's
`src/agents/tool-call-id.ts` and `src/agents/transcript-policy.ts`.

1
svc/llm/__init__.py Normal file
View file

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

69
svc/llm/chat_models.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
"""Compatibility re-export layer.
The codebase imports `ChatModel/OpenAIChatModel/...` from this module.
Implementations live under `src.platform.llm.transports.*`.
"""
import os
from svc.llm.tool_schema import shrink_openai_tools_payload_for_api
from svc.llm.tool_schema import openai_tools_json_byte_length as _openai_tools_json_byte_length
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload
from svc.llm.transports.google_gemini_sse import GoogleGeminiChatModel
from svc.llm.transports.openai_chat_completions import (
OpenAIChatModel,
_likely_gemini_openai_compat_base_url,
_model_id_suggests_gemini,
)
from svc.llm.transports.simple import RuleBasedChatModel, StaticTextChatModel
# Backward-compat alias used by `src/chat/agent.py`.
_normalize_image_b64_payload = normalize_image_b64_payload
def gemini_openai_compat_client(model: ChatModel) -> bool:
if not isinstance(model, OpenAIChatModel):
return False
return _likely_gemini_openai_compat_base_url(getattr(model, "base_url", None)) or _model_id_suggests_gemini(
getattr(model, "model", None)
)
def build_default_model() -> ChatModel:
mode = (os.getenv("AIA_ASSISTANT_MODE") or "").strip().lower()
if mode == "rule":
return RuleBasedChatModel()
if mode == "ollama":
try:
from runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
return OpenAIChatModel(model=DEFAULT_OLLAMA_MODEL, api_key="ollama", base_url=DEFAULT_OLLAMA_BASE_URL)
except Exception:
return RuleBasedChatModel()
if mode == "openai" or mode == "":
try:
return OpenAIChatModel()
except Exception:
return RuleBasedChatModel()
# Provider selection is normally handled via LLM profiles in `src/agents/factory.py`.
return RuleBasedChatModel()
__all__ = [
"ChatModel",
"LLMResponse",
"LLMToolCall",
"OpenAIChatModel",
"GoogleGeminiChatModel",
"RuleBasedChatModel",
"StaticTextChatModel",
"build_default_model",
"gemini_openai_compat_client",
"normalize_image_b64_payload",
"_normalize_image_b64_payload",
"shrink_openai_tools_payload_for_api",
"_openai_tools_json_byte_length",
]

View file

@ -0,0 +1,550 @@
"""Shared HTTP helpers for OCR and legacy image lanes (internal)."""
from __future__ import annotations
import base64
import io
import json
import os
import re
import time
from typing import Any
import httpx
from PIL import Image
def join_url(base: str, path: str) -> str:
b = (base or "").rstrip("/")
p = (path or "").lstrip("/")
return f"{b}/{p}"
def dashscope_native_multimodal_url_from_compatible_base(base_url: str) -> str | None:
"""
``qwen-image`` and similar models often return an **empty** OpenAI ``chat/completions`` message on
``/compatible-mode/v1``. Native HTTP uses ``/api/v1/services/aigc/multimodal-generation/generation``
and returns ``output.choices[].message.content[].image`` URLs.
Override: ``AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL`` (full URL).
Path suffix: ``AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_PATH`` (default multimodal-generation path).
"""
explicit = (os.getenv("AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL") or "").strip().rstrip("/")
if explicit:
return explicit
b = (base_url or "").strip().rstrip("/")
if not b or "compatible-mode" not in b.lower():
return None
root = re.sub(r"/compatible-mode/v\d+(?:/.*)?$", "", b, flags=re.IGNORECASE).rstrip("/")
if not root or root == b:
root = re.sub(r"/compatible-mode/?$", "", b, flags=re.IGNORECASE).rstrip("/")
if not root:
return None
path = (os.getenv("AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_PATH") or "").strip().lstrip("/")
if not path:
path = "api/v1/services/aigc/multimodal-generation/generation"
return f"{root}/{path}"
def env_ocr_lane_api_key() -> str:
return (os.getenv("AIA_OCR_API_KEY") or "").strip()
def env_ocr_lane_base_url() -> str:
return (os.getenv("AIA_OCR_BASE_URL") or "").strip()
def env_ocr_lane_model() -> str:
return (os.getenv("AIA_OCR_MODEL") or "").strip()
def env_ocr_lane_chat_endpoint() -> str:
raw = (os.getenv("AIA_OCR_CHAT_ENDPOINT") or "").strip()
return raw or "/chat/completions"
def env_image_expert_api_key() -> str:
"""Bearer key for **image specialist** multimodal/gen HTTP only (never shared with OCR ``AIA_OCR_*``)."""
return (os.getenv("AIA_IMAGE_EXPERT_API_KEY") or "").strip()
def env_image_expert_base_url() -> str:
return (os.getenv("AIA_IMAGE_EXPERT_BASE_URL") or "").strip()
def env_image_expert_model() -> str:
return (os.getenv("AIA_IMAGE_EXPERT_MODEL") or "").strip()
def env_image_expert_chat_endpoint() -> str:
raw = (os.getenv("AIA_IMAGE_EXPERT_CHAT_ENDPOINT") or "").strip()
return raw or "/chat/completions"
def is_data_url(s: str) -> bool:
return s.startswith("data:") and ";base64," in s
def compress_data_url_image(
data_url: str,
*,
max_side: int = 1600,
max_bytes: int = 2 * 1024 * 1024,
jpeg_quality: int = 82,
) -> str:
if not is_data_url(data_url):
return data_url
try:
header, b64 = data_url.split(";base64,", 1)
mime = header.replace("data:", "", 1).strip() or "image/jpeg"
raw = base64.b64decode(b64.encode("ascii"))
if len(raw) <= max_bytes:
return data_url
with Image.open(io.BytesIO(raw)) as im:
im = im.convert("RGB")
w, h = im.size
longest = max(w, h)
if longest > max_side:
scale = max_side / float(longest)
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
im = im.resize((nw, nh), Image.Resampling.LANCZOS)
out = io.BytesIO()
im.save(out, format="JPEG", quality=max(30, min(95, int(jpeg_quality))), optimize=True)
blob = out.getvalue()
if not blob:
return data_url
b64_new = base64.b64encode(blob).decode("ascii")
return f"data:{'image/jpeg' if mime.startswith('image/') else mime};base64,{b64_new}"
except Exception:
return data_url
def post_with_retry(
client: httpx.Client,
*,
url: str,
headers: dict[str, str],
payload: dict[str, Any],
retries: int = 3,
backoff_sec: float = 0.8,
) -> httpx.Response:
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
try:
return client.post(url, headers=headers, json=payload)
except (httpx.ReadError, httpx.ConnectError, httpx.TimeoutException) as e:
last_exc = e
if attempt >= retries:
break
time.sleep(backoff_sec * (2 ** (attempt - 1)))
assert last_exc is not None
raise last_exc
def env_image_expert_download_timeout_sec() -> float:
"""Read timeout for DashScope/OSS signed result URLs (official samples use ~300s)."""
raw = (os.getenv("AIA_IMAGE_EXPERT_DOWNLOAD_TIMEOUT_SEC") or "").strip()
if not raw:
return 300.0
try:
v = float(raw)
except ValueError:
return 300.0
return max(15.0, min(v, 900.0))
def download_http_url_bytes(
url: str,
*,
timeout_sec: float | None = None,
user_agent: str | None = None,
) -> tuple[bytes, str]:
"""Streamed GET with ``raise_for_status`` — matches DashScope OSS download guidance (long reads)."""
t = float(timeout_sec) if timeout_sec is not None else env_image_expert_download_timeout_sec()
ua = (user_agent or "").strip() or (
"Mozilla/5.0 (compatible; oclaw-image-expert/1.0; +https://github.com/)"
)
req_headers = {"User-Agent": ua, "Accept": "*/*"}
connect_cap = min(45.0, max(10.0, t / 10.0))
timeout = httpx.Timeout(t, connect=connect_cap)
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
with client.stream("GET", url, headers=req_headers) as r:
r.raise_for_status()
ctype = str(r.headers.get("content-type") or "").split(";", 1)[0].strip()
parts: list[bytes] = []
for chunk in r.iter_bytes(chunk_size=65_536):
parts.append(chunk)
return b"".join(parts), ctype
def dashscope_multimodal_http_ok(body: dict[str, Any]) -> tuple[bool, str]:
"""DashScope HTTP often uses HTTP 200 with a business-level ``code`` for failures."""
sc = body.get("status_code")
if isinstance(sc, str) and sc.strip().isdigit():
sc = int(sc.strip())
if isinstance(sc, int) and sc >= 400:
return False, str(body.get("message") or body.get("msg") or f"status_code={sc}")
code = body.get("code")
if code is None:
return True, ""
if isinstance(code, int):
if code == 200:
return True, ""
msg = str(body.get("message") or body.get("msg") or "").strip()
return False, msg or str(code)
cs = str(code).strip()
if cs == "":
return True, ""
if cs.lower() in ("success", "ok", "200"):
return True, ""
msg = str(body.get("message") or body.get("msg") or "").strip()
return False, msg or cs
def _extract_response_roots(resp_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Collect dict slices that may carry ``choices`` / ``messages`` (DashScope nests under ``output``)."""
roots: list[dict[str, Any]] = []
seen: set[int] = set()
def push(d: dict[str, Any]) -> None:
i = id(d)
if i in seen:
return
seen.add(i)
roots.append(d)
push(resp_json)
nested = resp_json.get("output")
if isinstance(nested, dict):
push(nested)
deeper = nested.get("output")
if isinstance(deeper, dict):
push(deeper)
result = resp_json.get("result")
if isinstance(result, dict):
push(result)
data_obj = resp_json.get("data")
if isinstance(data_obj, dict):
push(data_obj)
return roots
def _append_image_from_any(images: list[str], raw: Any) -> None:
if isinstance(raw, str) and raw.strip():
s = raw.strip()
if s.startswith(("http://", "https://", "data:")):
images.append(s)
return
if isinstance(raw, dict):
u = raw.get("url")
if isinstance(u, str) and u.strip() and u.strip().startswith(("http://", "https://")):
images.append(u.strip())
def _parse_content_part(it: Any, *, text_parts: list[str], images: list[str]) -> None:
"""Parse one multimodal element (DashScope shorthand, OpenAI-style typed blocks, etc.)."""
if isinstance(it, str) and it.strip():
text_parts.append(it.strip())
return
if not isinstance(it, dict):
return
typ = str(it.get("type") or "").strip().lower()
if typ == "text":
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
return
if typ == "input_text":
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
return
if typ == "image_url":
iu = it.get("image_url")
if isinstance(iu, dict):
_append_image_from_any(images, iu)
elif isinstance(iu, str):
_append_image_from_any(images, iu)
return
if typ == "input_image":
iu = it.get("image_url")
if isinstance(iu, dict):
_append_image_from_any(images, iu)
elif isinstance(iu, str):
_append_image_from_any(images, iu)
return
if typ == "image" and it.get("image") is not None:
_append_image_from_any(images, it.get("image"))
return
# Untyped / DashScope shorthand blocks
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
if it.get("image") is not None:
_append_image_from_any(images, it.get("image"))
ius = it.get("image_url")
if isinstance(ius, str):
_append_image_from_any(images, ius)
elif isinstance(ius, dict):
_append_image_from_any(images, ius)
uu = it.get("url")
if isinstance(uu, str) and uu.strip().startswith(("http://", "https://")):
images.append(uu.strip())
if isinstance(it.get("b64_json"), str) and str(it.get("b64_json")).strip():
images.append(f"data:image/png;base64,{it['b64_json']}")
def _try_parse_content_json_string(raw: str, *, text_parts: list[str], images: list[str]) -> bool:
"""Some proxies stringify multimodal ``content`` as JSON."""
s = raw.strip()
if not s or s[0] not in "[{":
return False
try:
parsed = json.loads(s)
except Exception:
return False
if isinstance(parsed, list):
for it in parsed:
_parse_content_part(it, text_parts=text_parts, images=images)
return True
if isinstance(parsed, dict):
_parse_content_part(parsed, text_parts=text_parts, images=images)
return True
return False
def _harvest_http_urls_from_value(val: Any, images: list[str], *, depth: int, max_depth: int = 6) -> None:
"""Last resort: collect image URLs nested under ``message`` (unknown multimodal block shapes)."""
if depth > max_depth or val is None:
return
if isinstance(val, str):
s = val.strip()
if s.startswith(("http://", "https://")) and s not in images:
images.append(s)
return
if isinstance(val, dict):
for _k, v in val.items():
_harvest_http_urls_from_value(v, images, depth=depth + 1, max_depth=max_depth)
return
if isinstance(val, list):
for it in val:
_harvest_http_urls_from_value(it, images, depth=depth + 1, max_depth=max_depth)
def _parse_choice_message_content(msg: dict[str, Any], *, text_parts: list[str], images: list[str]) -> None:
c = msg.get("content")
if c is None:
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, str):
if c.strip() and _try_parse_content_json_string(c, text_parts=text_parts, images=images):
_harvest_http_urls_from_value(msg, images, depth=0)
return
if c.strip():
text_parts.append(c.strip())
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, dict):
_parse_content_part(c, text_parts=text_parts, images=images)
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, list):
for it in c:
_parse_content_part(it, text_parts=text_parts, images=images)
_harvest_http_urls_from_value(msg, images, depth=0)
return
def _parse_messages_array_for_assistant(
messages: list[Any], *, text_parts: list[str], images: list[str]
) -> None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if str(m.get("role") or "").strip().lower() != "assistant":
continue
_parse_choice_message_content(m, text_parts=text_parts, images=images)
break
def extract_text_and_images(resp_json: dict[str, Any]) -> tuple[str, list[str]]:
text_parts: list[str] = []
images: list[str] = []
roots = _extract_response_roots(resp_json)
for root in roots:
ot = root.get("text")
if isinstance(ot, str) and ot.strip():
text_parts.append(ot.strip())
choices: list[Any] | None = None
for root in roots:
ch = root.get("choices")
if isinstance(ch, list) and ch:
choices = ch
break
if isinstance(choices, list) and choices:
for choice in choices:
if not isinstance(choice, dict):
continue
legacy_txt = choice.get("text")
if isinstance(legacy_txt, str) and legacy_txt.strip():
text_parts.append(legacy_txt.strip())
msg = choice.get("message")
if isinstance(msg, dict):
_parse_choice_message_content(msg, text_parts=text_parts, images=images)
elif not isinstance(msg, dict):
_harvest_http_urls_from_value(choice, images, depth=0)
if not text_parts and not images:
for root in roots:
msgs = root.get("messages")
if isinstance(msgs, list) and msgs:
_parse_messages_array_for_assistant(msgs, text_parts=text_parts, images=images)
if text_parts or images:
break
if not images:
for root in roots:
data = root.get("data")
if not isinstance(data, list):
continue
for it in data:
if not isinstance(it, dict):
continue
if isinstance(it.get("url"), str) and str(it.get("url")).strip():
images.append(str(it.get("url")).strip())
if isinstance(it.get("b64_json"), str) and str(it.get("b64_json")).strip():
images.append(f"data:image/png;base64,{it['b64_json']}")
if not images:
for root in roots:
for key in ("results", "artifacts", "task_outputs"):
arr = root.get(key)
if not isinstance(arr, list):
continue
for it in arr:
if not isinstance(it, dict):
continue
for ik in ("image", "url", "output_image_url", "image_url"):
v = it.get(ik)
if isinstance(v, str) and v.strip().startswith(("http://", "https://")):
images.append(v.strip())
elif isinstance(v, dict) and isinstance(v.get("url"), str):
u = str(v.get("url")).strip()
if u.startswith(("http://", "https://")):
images.append(u)
# De-dupe URLs while preserving order
seen_u: set[str] = set()
uniq_images: list[str] = []
for u in images:
if u not in seen_u:
seen_u.add(u)
uniq_images.append(u)
images = uniq_images
return "\n".join([x for x in text_parts if x]).strip(), images
def _diag_fill_choices(out: dict[str, Any], ch: Any, *, prefix: str) -> None:
out[f"{prefix}choices_typename"] = type(ch).__name__
out[f"{prefix}choices_len"] = len(ch) if isinstance(ch, list) else None
if not isinstance(ch, list) or not ch or not isinstance(ch[0], dict):
return
c0 = ch[0]
out[f"{prefix}choice0_keys"] = sorted(c0.keys())[:24]
msg = c0.get("message")
if isinstance(msg, dict):
out[f"{prefix}msg_keys"] = sorted(msg.keys())[:24]
c = msg.get("content")
out[f"{prefix}content_typename"] = type(c).__name__
if isinstance(c, list):
out[f"{prefix}content_len"] = len(c)
if c:
out[f"{prefix}content0_typename"] = type(c[0]).__name__
if isinstance(c[0], dict):
out[f"{prefix}content0_keys"] = sorted(c[0].keys())[:24]
elif isinstance(c, dict):
out[f"{prefix}content_keys"] = sorted(c.keys())[:24]
elif c is None:
out[f"{prefix}content_is_null"] = True
def build_extract_diag_empty(obj: dict[str, Any]) -> dict[str, Any]:
"""Compact shape hints when extraction yielded nothing (stderr / chat debug)."""
out: dict[str, Any] = {"top_level_keys": sorted(obj.keys())[:40]}
ch_top = obj.get("choices")
if ch_top is not None:
_diag_fill_choices(out, ch_top, prefix="")
outp = obj.get("output")
if isinstance(outp, dict):
out["output_keys"] = sorted(outp.keys())[:40]
ch = outp.get("choices")
_diag_fill_choices(out, ch, prefix="output_")
return out
def redact_response_for_debug(obj: Any, *, max_chars: int = 2800) -> str:
"""JSON preview for error surfaces (truncate; redact long base64 / data URLs)."""
def _walk(x: Any) -> Any:
if isinstance(x, dict):
return {str(k): _walk(v) for k, v in x.items()}
if isinstance(x, list):
return [_walk(v) for v in x[:80]]
if isinstance(x, str):
s = x
if ";base64," in s and s.strip().startswith("data:") and len(s) > 120:
h, _, _ = s.partition(";base64,")
return f"{h};base64,<redacted {len(s)} chars>"
if len(s) > 500:
return s[:400] + f"...<truncated {len(s) - 400}>"
return s
return x
try:
txt = json.dumps(_walk(obj), ensure_ascii=False, default=str)
except Exception:
txt = str(obj)
if len(txt) > max_chars:
return txt[: max_chars - 24] + "\n…<truncated>"
return txt
def format_extract_diag(diag: dict[str, Any], *, max_chars: int = 900) -> str:
try:
s = json.dumps(diag, ensure_ascii=False, default=str)
except Exception:
s = str(diag)
if len(s) > max_chars:
return s[: max_chars - 20] + "…<truncated>"
return s
__all__ = [
"build_extract_diag_empty",
"compress_data_url_image",
"dashscope_multimodal_http_ok",
"dashscope_native_multimodal_url_from_compatible_base",
"download_http_url_bytes",
"env_image_expert_download_timeout_sec",
"redact_response_for_debug",
"env_image_expert_api_key",
"env_image_expert_base_url",
"env_image_expert_chat_endpoint",
"env_image_expert_model",
"env_ocr_lane_api_key",
"env_ocr_lane_base_url",
"env_ocr_lane_chat_endpoint",
"env_ocr_lane_model",
"extract_text_and_images",
"format_extract_diag",
"is_data_url",
"join_url",
"post_with_retry",
]

View file

@ -0,0 +1,733 @@
"""Legacy image+text payloads for DashScope-style gateways.
Uses ``{"image":...}/{"text":...}`` or typed compatible-mode blocks on ``/chat/completions`` only.
The **image specialist** uses this module from:
- :mod:`~runtime.agents.specialist_agent` (orchestration temp sessions)
- :mod:`~runtime.direct_loop` when ``skill_binding_role=="image"`` (**gateway /chat UI**), so vision
turns never hit :class:`~svc.llm.transports.openai_responses.OpenAIResponsesModel` unless explicitly disabled via env.
Alignment with Alibaba ``dashscope.MultiModalConversation`` examples (**message shape**):
``messages = [{"role": "user", "content": [{"image": "<url or data:...>"}, {"text": "..."}]}]``
matches our non--``typed`` branch (same ``image`` / ``text`` keys as the SDK doc).
Lane separation (**not** OCR):
- Resolved as ``kwargs …`` from the user's **chosen chat model/profile** first, then ``AIA_IMAGE_EXPERT_*`` when a field is empty.
The **`AIA_OCR_*`** variables remain for **`query_image_attachment`** / OCR downgrade only.
- Bearer HTTP targets ``BASE_URL`` + ``AIA_IMAGE_EXPERT_CHAT_ENDPOINT`` (default ``/chat/completions``), not ``MultiModalConversation`` SDK.
SDK-style extras:
- Optional top-level fields (``stream``, ``n``, ``watermark``, ``negative_prompt``, ``prompt_extend``, ``size``, …):
use ``DASHSCOPE_IMAGE_*`` env vars or JSON in ``AIA_IMAGE_EXPERT_REQUEST_EXTRA`` (alias: ``AIA_LEGACY_IMAGE_REQUEST_EXTRA``).
Compatibility roots:
- ``compatible-mode/v1`` **默认**使用 OpenAI Chat Completions 视觉块(每条含 ``type``:``image_url`` / ``text``);百炼兼容网关否则会报 ``missing_required_parameter … content[n].type``。若网关只吃 DashScope 文档形态(无 ``type`` 的 ``{"image"}``/``{"text"}``),设置 ``AIA_IMAGE_EXPERT_COMPAT_USE_DASHSCOPE_NATIVE_BLOCKS=1``。
For OpenAI-style ``image_url`` chat payloads (tool OCR / multimodal downgrade), use :mod:`svc.llm.image_ocr_client`.
Chat UI **图片专家**端到端说明(与其它链路隔离的变更边界)见仓库内 ``docs/IMAGE_SPECIALIST_LANE.md``.
"""
from __future__ import annotations
import base64
import json
import os
import re
import sys
from typing import Any
import httpx
from svc.files.attachment_assets import AttachmentAssetStore
from svc.llm.image_http_common import (
build_extract_diag_empty,
compress_data_url_image,
dashscope_multimodal_http_ok,
dashscope_native_multimodal_url_from_compatible_base,
download_http_url_bytes,
env_image_expert_api_key,
env_image_expert_base_url,
env_image_expert_chat_endpoint,
env_image_expert_model,
extract_text_and_images,
format_extract_diag,
is_data_url,
join_url,
post_with_retry,
redact_response_for_debug,
)
from runtime.prompt_templates import render_prompt
IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH = (
"请根据用户上传的图片作答:描述可见场景、物体与文字;不确定处请标明。"
)
def _max_legacy_input_images() -> int:
try:
v = int(os.getenv("AIA_IMAGE_EXPERT_MAX_INPUT_IMAGES") or "8")
except Exception:
v = 8
return max(1, min(int(v), 12))
def parse_message_attachments_json(raw: Any) -> list[dict[str, Any]]:
"""Decode ``chat_message.attachments`` (JSON string, list, or dict) into attachment dict rows."""
if raw is None:
return []
if isinstance(raw, list):
return [x for x in raw if isinstance(x, dict)]
if isinstance(raw, dict):
return [raw]
if isinstance(raw, str):
s = raw.strip()
if not s or s.lower() == "null":
return []
try:
v = json.loads(s)
if isinstance(v, list):
return [x for x in v if isinstance(x, dict)]
if isinstance(v, dict):
return [v]
except Exception:
pass
return []
def collect_legacy_lane_images_from_attachments(
attachments: list[dict[str, Any]] | None,
*,
max_images: int | None = None,
) -> list[str]:
"""Normalize incoming UI/store attachments to URLs/data URLs for :func:`send_legacy_image_messages`."""
from svc.files.attachment_assets import attachment_id_to_data_url
cap = max(1, min(int(max_images if max_images is not None else _max_legacy_input_images()), 12))
out: list[str] = []
for att in attachments or []:
if not isinstance(att, dict):
continue
t = str(att.get("type") or "").strip().lower()
if t == "image_ref":
aid = str(att.get("attachment_id") or "").strip()
if not aid:
continue
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
if data_url:
out.append(data_url)
elif t in ("input_image", "image"):
raw = str(att.get("image_base64") or att.get("data") or "").strip()
if raw:
mime = str(att.get("mime") or "image/jpeg")
if raw.startswith("data:"):
out.append(raw)
else:
out.append(f"data:{mime};base64,{raw}")
elif t == "image_url":
u = str(att.get("url") or "").strip()
if u:
out.append(u)
elif t == "relay_pointer":
mime = str(att.get("mime") or "").strip().lower()
if mime and not mime.startswith("image/"):
continue
uri = str(att.get("pointer_uri") or "").strip()
aid = str(att.get("attachment_id") or "").strip()
if not aid and uri:
m = re.match(r"^relay://attachments/[^/]+/([a-f0-9]{8,128})$", uri, re.I)
if m:
aid = str(m.group(1)).lower()
if aid:
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
if data_url:
out.append(data_url)
if len(out) >= cap:
break
return out
def collect_legacy_lane_images_with_session_fallback(
*,
store: Any,
session_id: str,
attachments: list[dict[str, Any]] | None,
max_images: int | None = None,
) -> tuple[list[str], str]:
"""Resolve legacy multimodal image URLs for the current turn, then session history.
When the current request has no embedded/URL images, scan persisted messages (newest→oldest):
1) assistant rows with usable image attachments (e.g. last model output);
2) user rows with images.
The trailing user bubble for this turn (text-only) is skipped when scanning.
Returns ``(images, source)`` where ``source`` is ``current``, ``assistant_history``,
``user_history``, or empty when nothing was found.
Disable with ``AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK=0`` (default: on).
"""
cap = max_images if max_images is not None else _max_legacy_input_images()
primary = collect_legacy_lane_images_from_attachments(attachments, max_images=cap)
if primary:
return primary, "current"
if str(os.getenv("AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK") or "").strip().lower() in (
"0",
"false",
"no",
"off",
):
return [], ""
try:
scan_n = int(os.getenv("AIA_IMAGE_SPECIALIST_FALLBACK_SCAN_MESSAGES") or "400")
except Exception:
scan_n = 400
scan_n = max(1, min(scan_n, 2000))
try:
msgs = store.get_messages(str(session_id or "").strip(), limit=scan_n)
except Exception:
msgs = []
if not msgs:
return [], ""
hi = len(msgs) - 1
if hi >= 0:
last = msgs[hi]
if str(getattr(last, "role", "") or "").strip().lower() == "user":
la = parse_message_attachments_json(getattr(last, "attachments", None))
if not collect_legacy_lane_images_from_attachments(la, max_images=1):
hi -= 1
for phase in ("assistant", "user"):
for i in range(hi, -1, -1):
m = msgs[i]
if str(getattr(m, "role", "") or "").strip().lower() != phase:
continue
la = parse_message_attachments_json(getattr(m, "attachments", None))
got = collect_legacy_lane_images_from_attachments(la, max_images=cap)
if got:
return got, f"{phase}_history"
return [], ""
def normalize_legacy_output_image_urls(resp_images: Any, *, max_items: int = 12) -> list[str]:
"""Flatten provider ``images`` / content parts to HTTP/data URLs (strings only)."""
cap = max(1, min(int(max_items), 24))
out: list[str] = []
if resp_images is None:
return out
if isinstance(resp_images, str):
s = resp_images.strip()
if s:
out.append(s)
return out[:cap]
if not isinstance(resp_images, list):
return out
for it in resp_images[:cap]:
if isinstance(it, str):
s = it.strip()
if s:
out.append(s)
continue
if not isinstance(it, dict):
continue
u = it.get("image")
if isinstance(u, str) and u.strip():
out.append(u.strip())
continue
u = it.get("url")
if isinstance(u, str) and u.strip():
out.append(u.strip())
continue
iu = it.get("image_url")
if isinstance(iu, str) and iu.strip():
out.append(iu.strip())
elif isinstance(iu, dict):
u2 = iu.get("url")
if isinstance(u2, str) and u2.strip():
out.append(u2.strip())
return out[:cap]
def materialize_legacy_response_output_attachments(
resp_images: Any,
*,
max_images: int = 3,
) -> list[dict[str, Any]]:
"""Persist remote/base64 model outputs as ``image_ref`` / ``image_url`` rows for chat UI."""
cap = max(1, min(int(max_images), 12))
produced: list[dict[str, Any]] = []
urls = normalize_legacy_output_image_urls(resp_images, max_items=cap)
if not urls:
return produced
store = AttachmentAssetStore()
for idx, item in enumerate(urls[:cap], start=1):
s = str(item or "").strip()
if not s:
continue
if s.startswith("data:") and ";base64," in s:
head, b64 = s.split(";base64,", 1)
mime = head.replace("data:", "", 1) or "image/png"
try:
blob = base64.b64decode(b64.encode("ascii"))
except Exception:
continue
meta = store.save_bytes(blob, filename=f"image-output-{idx}.png", mime=mime)
produced.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
elif s.startswith("http://") or s.startswith("https://"):
try:
blob, ctype = download_http_url_bytes(s)
if blob:
mime = (ctype.split(";", 1)[0].strip() if ctype else "") or "image/png"
ext = ".png"
if mime == "image/jpeg":
ext = ".jpg"
elif mime == "image/webp":
ext = ".webp"
elif mime == "image/gif":
ext = ".gif"
meta = store.save_bytes(blob, filename=f"image-output-{idx}{ext}", mime=mime)
produced.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
continue
except Exception:
pass
produced.append({"type": "image_url", "url": s, "name": f"image-output-{idx}.png"})
return produced
def legacy_image_turn_bundle(resp: dict[str, Any]) -> tuple[bool, str, list[dict[str, Any]]]:
"""Interpret ``send_legacy_image_messages`` result for persistence (text-only vision answers allowed)."""
ok = bool(resp.get("ok"))
text = str(resp.get("text") or "").strip()
if not ok:
err = str(resp.get("error") or "").strip()
return False, f"Image generation failed: {err or 'unknown error'}", []
raw_urls = normalize_legacy_output_image_urls(resp.get("images"), max_items=6)
imgs = materialize_legacy_response_output_attachments(raw_urls, max_images=3)
if not imgs and raw_urls:
imgs = [
{"type": "image_url", "url": u, "name": f"image-output-{i}.png"}
for i, u in enumerate(raw_urls[:3], start=1)
if u.startswith(("http://", "https://", "data:"))
]
if imgs:
return True, text, imgs
if text:
return True, text, []
diag = resp.get("extract_diag")
hint = ""
if isinstance(diag, dict) and diag:
hint = format_extract_diag(diag, max_chars=1400)
red = resp.get("provider_response_redacted")
if isinstance(red, str) and red.strip():
hint = f"{hint}\nprovider_json={red.strip()}" if hint else f"provider_json={red.strip()}"
base = "Image specialist failed: empty response from provider."
out_msg = f"{base} {hint}".strip()
if len(out_msg) > 12_000:
out_msg = out_msg[:11_980] + "\n…<truncated>"
return False, out_msg, []
def legacy_image_assistant_body_with_placeholder(
*,
lang: str | None,
body_text: str,
produced: list[dict[str, Any]] | None,
) -> str:
"""If the model returned images but no visible text, use the standard chat placeholder (ZH/EN).
Shared by ``direct_loop`` (gateway /chat) and ``specialist_agent`` (temp sessions).
"""
if str(body_text or "").strip():
return str(body_text or "")
if produced:
return (
"Generated image (see attachment below)."
if str(lang or "").startswith("en")
else "已生成图片(见下方附件)。"
)
return str(body_text or "")
def _truthy_env(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
def _redact_payload_for_stderr(obj: Any) -> Any:
if isinstance(obj, dict):
return {str(k): _redact_payload_for_stderr(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact_payload_for_stderr(x) for x in obj]
if isinstance(obj, str):
s = obj
if ";base64," in s and s.strip().startswith("data:") and len(s) > 120:
h, _, _ = s.partition(";base64,")
return f"{h};base64,<redacted ~{len(s) - len(h) - 8} chars>"
return s
return obj
def _stderr_debug_image_legacy(url: str, payload: dict[str, Any]) -> None:
if not _truthy_env("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD"):
return
try:
txt = json.dumps(_redact_payload_for_stderr(dict(payload)), ensure_ascii=False, indent=2, default=str)
sys.stderr.write(f"\n[oclaw image_legacy] POST {url}\n{txt}\n\n")
sys.stderr.flush()
except Exception:
pass
def _extra_request_fields_from_env() -> dict[str, Any]:
"""Merge JSON from ``AIA_IMAGE_EXPERT_REQUEST_EXTRA`` (or legacy alias ``AIA_LEGACY_IMAGE_REQUEST_EXTRA``)."""
raw = (os.getenv("AIA_IMAGE_EXPERT_REQUEST_EXTRA") or os.getenv("AIA_LEGACY_IMAGE_REQUEST_EXTRA") or "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _dashscope_image_env_kw() -> dict[str, Any]:
"""Map `_local/system.env.example` ``DASHSCOPE_IMAGE_*`` vars to multimodal/top-level kwargs (parity with SDK samples)."""
out: dict[str, Any] = {}
raw_n = (os.getenv("DASHSCOPE_IMAGE_N") or "").strip()
if raw_n.isdigit():
out["n"] = max(1, min(int(raw_n), 6))
wm = (os.getenv("DASHSCOPE_IMAGE_WATERMARK") or "").strip().lower()
if wm in ("1", "true", "yes", "on"):
out["watermark"] = True
elif wm in ("0", "false", "no", "off"):
out["watermark"] = False
raw_stream = (os.getenv("DASHSCOPE_IMAGE_STREAM") or "").strip().lower()
if raw_stream in ("1", "true", "yes", "on"):
out["stream"] = True
elif raw_stream in ("0", "false", "no", "off"):
out["stream"] = False
neg = os.getenv("DASHSCOPE_IMAGE_NEGATIVE_PROMPT")
if neg is not None:
ns = str(neg)
if ns.strip():
out["negative_prompt"] = ns
pe = (os.getenv("DASHSCOPE_IMAGE_PROMPT_EXTEND") or "").strip().lower()
if pe in ("1", "true", "yes", "on"):
out["prompt_extend"] = True
elif pe in ("0", "false", "no", "off"):
out["prompt_extend"] = False
size = (os.getenv("DASHSCOPE_IMAGE_SIZE") or "").strip()
if size:
out["size"] = size
return out
def _openai_compatible_vision_content(images: list[str], prompt_text: str) -> list[dict[str, Any]]:
"""OpenAI Chat Completions vision shape (``type`` + ``image_url`` / ``text``).
Required for typical ``compatible-mode/v1`` ``/chat/completions`` (each part must have ``type``).
Image URLs may be ``https://…`` or ``data:image/…;base64,…``.
"""
blocks: list[dict[str, Any]] = []
for img in images:
blocks.append({"type": "image_url", "image_url": {"url": img}})
blocks.append({"type": "text", "text": str(prompt_text or "").strip()})
return blocks
def _compat_use_dashscope_native_blocks() -> bool:
"""When ``1``, ``compatible-mode`` URLs use DashScope doc blocks without ``type`` (see :func:`_http_content_blocks`)."""
return str(os.getenv("AIA_IMAGE_EXPERT_COMPAT_USE_DASHSCOPE_NATIVE_BLOCKS") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
def _model_triggers_dashscope_native_fallback(model_name: str) -> bool:
"""``qwen-image`` on OpenAI-compat ``/chat/completions`` often returns ``message.content=null``."""
if str(os.getenv("AIA_IMAGE_EXPERT_DISABLE_DASHSCOPE_NATIVE_FALLBACK") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
return False
if str(os.getenv("AIA_IMAGE_EXPERT_FORCE_DASHSCOPE_NATIVE") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
return True
m = (model_name or "").strip().lower()
needle = (os.getenv("AIA_IMAGE_EXPERT_NATIVE_FALLBACK_MODEL_SUBSTR") or "qwen-image").strip().lower()
return bool(needle) and needle in m
def _http_content_blocks(images: list[str], prompt: str, *, typed: bool) -> list[dict[str, Any]]:
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
if not typed:
blocks: list[dict[str, Any]] = [{"image": img} for img in images]
blocks.append({"text": prompt_text})
return blocks
blocks_typed: list[dict[str, Any]] = [{"type": "image", "image": img} for img in images]
blocks_typed.append({"type": "text", "text": prompt_text})
return blocks_typed
def send_legacy_image_messages(
*,
images: list[str],
prompt: str,
model: str | None = None,
timeout_sec: int = 60,
api_key: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""Legacy multimodal HTTP (non--OpenAI-``image_url`` schema); image specialist uses this entry point."""
resolved_base_url = (base_url or env_image_expert_base_url()).strip()
resolved_api_key = (api_key or env_image_expert_api_key()).strip()
model_name = ((model or "").strip() or env_image_expert_model())
endpoint = env_image_expert_chat_endpoint()
url = join_url(resolved_base_url, endpoint)
if not resolved_api_key or not resolved_base_url:
return {
"ok": False,
"error": "missing AIA_IMAGE_EXPERT_API_KEY or AIA_IMAGE_EXPERT_BASE_URL (or pass api_key and base_url)",
}
if not model_name:
return {
"ok": False,
"error": "missing model (chosen profile/model=… or set AIA_IMAGE_EXPERT_MODEL)",
}
if not images:
return {"ok": False, "error": "at least one image input is required"}
raw_selected = [str(x).strip() for x in images if str(x).strip()][: _max_legacy_input_images()]
selected: list[str] = []
input_kind: list[str] = []
for img in raw_selected:
if is_data_url(img):
selected.append(compress_data_url_image(img))
input_kind.append("data_url")
continue
if img.startswith("http://") or img.startswith("https://"):
selected.append(img)
input_kind.append("url")
continue
if not selected:
return {"ok": False, "error": "no usable image input (expected URL or data URL)"}
# compatible-mode /chat/completions is OpenAI-shaped: every content part needs ``type`` (else 400 missing content[n].type).
# Native DashScope HTTP (non-compatible base) uses ``{"image"}`` / ``{"text"}`` without ``type``.
use_compatible_base = "compatible-mode" in resolved_base_url.lower()
prompt_plain = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
if use_compatible_base and not _compat_use_dashscope_native_blocks():
content_multi = _openai_compatible_vision_content(selected, prompt_plain)
else:
content_multi = _http_content_blocks(selected, prompt, typed=False)
headers = {
"Authorization": f"Bearer {resolved_api_key}",
"Content-Type": "application/json",
}
extra_ds = _dashscope_image_env_kw()
extra_json = _extra_request_fields_from_env()
extra = {**extra_ds, **extra_json}
core_multi = {
"model": model_name,
"messages": [{"role": "user", "content": content_multi}],
}
payload_multi = {**extra, **core_multi}
_stderr_debug_image_legacy(url, payload_multi)
with httpx.Client(timeout=float(timeout_sec)) as client:
try:
r = post_with_retry(client, url=url, headers=headers, payload=payload_multi)
except Exception as e:
return {"ok": False, "error": f"http request failed: {type(e).__name__}: {e}", "backend_shape": "multi"}
if r.status_code >= 400:
return {"ok": False, "error": f"http {r.status_code}: {r.text[:800]}", "backend_shape": "multi"}
try:
obj = r.json()
except Exception:
return {"ok": False, "error": f"non-json response: {r.text[:500]}", "backend_shape": "multi"}
if not isinstance(obj, dict):
return {
"ok": False,
"error": f"expected JSON object from provider, got {type(obj).__name__}",
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
}
body = obj
ds_ok, ds_err = dashscope_multimodal_http_ok(body)
if not ds_ok:
return {
"ok": False,
"error": ds_err or "provider rejected request (DashScope code/message)",
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
}
text, out_images = extract_text_and_images(body)
compat_extract_diag: dict[str, Any] | None = None
native_fallback_meta: dict[str, Any] = {}
if (
not str(text or "").strip()
and not out_images
and use_compatible_base
and _model_triggers_dashscope_native_fallback(model_name)
):
compat_extract_diag = build_extract_diag_empty(body)
native_url = dashscope_native_multimodal_url_from_compatible_base(resolved_base_url)
if not native_url:
native_fallback_meta = {
"attempted": False,
"hint": "set AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL or use a compatible-mode base_url",
}
else:
content_native = _http_content_blocks(selected, prompt, typed=False)
param_merge = {**_dashscope_image_env_kw(), **_extra_request_fields_from_env()}
param_merge.pop("stream", None)
payload_native = {
"model": model_name,
"input": {"messages": [{"role": "user", "content": content_native}]},
"parameters": {"result_format": "message", **param_merge},
}
_stderr_debug_image_legacy(native_url, payload_native)
try:
r2 = post_with_retry(client, url=native_url, headers=headers, payload=payload_native)
except Exception as e:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": f"{type(e).__name__}: {e}",
}
else:
if r2.status_code >= 400:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"http_status": int(r2.status_code),
"body_head": r2.text[:600],
}
else:
try:
b2 = r2.json()
except Exception as e:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": f"json: {type(e).__name__}: {e}",
}
else:
if isinstance(b2, dict):
ok2, err2 = dashscope_multimodal_http_ok(b2)
if not ok2:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"dashscope_error": err2 or "business code",
}
else:
t2, im2 = extract_text_and_images(b2)
if str(t2 or "").strip() or im2:
text, out_images = t2, im2
body = b2
native_fallback_meta = {
"attempted": True,
"url": native_url,
"succeeded": True,
}
else:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"succeeded": False,
"native_diag": build_extract_diag_empty(b2),
}
else:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": "native response not a JSON object",
}
extract_diag: dict[str, Any] | None = None
provider_response_redacted: str | None = None
if not str(text or "").strip() and not out_images:
extract_diag = build_extract_diag_empty(body)
if compat_extract_diag is not None:
extract_diag["openai_compat_empty"] = compat_extract_diag
if native_fallback_meta:
extract_diag["dashscope_native_fallback"] = native_fallback_meta
provider_response_redacted = redact_response_for_debug(body, max_chars=3200)
if _truthy_env("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD"):
try:
sys.stderr.write(
"\n[oclaw image_legacy] extract_empty extract_diag="
+ format_extract_diag(extract_diag, max_chars=4000)
+ "\n"
+ (provider_response_redacted or "")
+ "\n\n"
)
sys.stderr.flush()
except Exception:
pass
out_images = normalize_legacy_output_image_urls(out_images, max_items=12)
return {
"ok": True,
"text": text,
"images": out_images,
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
"extract_diag": extract_diag,
"provider_response_redacted": provider_response_redacted,
}
__all__ = [
"IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH",
"collect_legacy_lane_images_from_attachments",
"collect_legacy_lane_images_with_session_fallback",
"parse_message_attachments_json",
"legacy_image_assistant_body_with_placeholder",
"legacy_image_turn_bundle",
"materialize_legacy_response_output_attachments",
"normalize_legacy_output_image_urls",
"send_legacy_image_messages",
]

200
svc/llm/image_ocr_client.py Normal file
View file

@ -0,0 +1,200 @@
"""OCR / vision lane: OpenAI-compatible multimodal ``image_url`` + ``text`` only."""
from __future__ import annotations
from typing import Any
import httpx
from svc.llm.image_http_common import (
compress_data_url_image,
env_ocr_lane_api_key,
env_ocr_lane_base_url,
env_ocr_lane_model,
env_ocr_lane_chat_endpoint,
extract_text_and_images,
is_data_url,
join_url,
post_with_retry,
)
from runtime.prompt_templates import render_prompt
# Shared with query_image_attachment and OpenAI-compat multimodal→text downgrade.
VISION_OCR_EXTRACT_PROMPT_ZH = (
"请只提取图片中可见文字并按阅读顺序输出。"
"如果有表格,保持行列结构;不确定的内容标注为[unclear]。"
)
VISION_DESCRIBE_PROMPT_ZH = (
"请详细描述这张图片的主要内容、对象、场景和可见文字。"
"回答请使用要点列表,避免臆测。"
)
def _http_content_blocks_openai_vision(images: list[str], prompt: str) -> list[dict[str, Any]]:
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
blocks: list[dict[str, Any]] = []
for img in images:
s = str(img or "").strip()
if not s:
continue
blocks.append({"type": "image_url", "image_url": {"url": s}})
if prompt_text:
blocks.append({"type": "text", "text": prompt_text})
return blocks
def vision_llm_backend_status(
*,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
) -> dict[str, Any]:
ak = (api_key or env_ocr_lane_api_key()).strip()
bu = (base_url or env_ocr_lane_base_url()).strip()
md = ((model or "").strip() or env_ocr_lane_model())
if ak and bu and md:
return {"ok": True, "backend": "aia_ocr_http"}
return {
"ok": False,
"backend": None,
"hint_zh": (
"未配置 OCR/看图通道:请同时设置 AIA_OCR_BASE_URL、AIA_OCR_API_KEY、AIA_OCR_MODEL"
"(OpenAI-compatible 多模态 /chat/completions)。不设默认模型 id。"
),
"hint_en": (
"OCR/vision lane not configured: set AIA_OCR_BASE_URL, AIA_OCR_API_KEY, and AIA_OCR_MODEL. "
"No default model id."
),
}
def send_ocr_image_messages(
*,
images: list[str],
prompt: str,
model: str | None = None,
timeout_sec: int = 60,
api_key: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""OpenAI Chat Completions multimodal only (``/chat/completions``): multi-image then single-image."""
resolved_base_url = (base_url or env_ocr_lane_base_url()).strip()
resolved_api_key = (api_key or env_ocr_lane_api_key()).strip()
model_name = ((model or "").strip() or env_ocr_lane_model())
endpoint = env_ocr_lane_chat_endpoint()
url = join_url(resolved_base_url, endpoint)
if not resolved_api_key or not resolved_base_url:
return {
"ok": False,
"error": "missing AIA_OCR_API_KEY or AIA_OCR_BASE_URL (or pass api_key and base_url)",
}
if not model_name:
return {
"ok": False,
"error": "missing AIA_OCR_MODEL (or pass model=...) — no default model id",
}
if not images:
return {"ok": False, "error": "at least one image input is required"}
raw_selected = [str(x).strip() for x in images if str(x).strip()][:3]
selected: list[str] = []
input_kind: list[str] = []
for img in raw_selected:
if is_data_url(img):
selected.append(compress_data_url_image(img))
input_kind.append("data_url")
continue
if img.startswith("http://") or img.startswith("https://"):
selected.append(img)
input_kind.append("url")
continue
if not selected:
return {"ok": False, "error": "no usable image input (expected URL or data URL)"}
content_openai = _http_content_blocks_openai_vision(selected, prompt)
content_openai_single = _http_content_blocks_openai_vision(selected[-1:], prompt) if selected else []
headers = {
"Authorization": f"Bearer {resolved_api_key}",
"Content-Type": "application/json",
}
payload_openai_multi = {
"model": model_name,
"messages": [{"role": "user", "content": content_openai}],
}
payload_openai_single = {
"model": model_name,
"messages": [{"role": "user", "content": content_openai_single}],
}
def _ok_response(resp: httpx.Response) -> tuple[dict[str, Any] | None, bool]:
if resp.status_code >= 400:
return None, False
try:
obj = resp.json()
except Exception:
return None, False
if not isinstance(obj, dict):
return None, False
text, out_imgs = extract_text_and_images(obj)
if not str(text or "").strip():
return obj, False
return {"text": text, "images": out_imgs}, True
err_last = ""
with httpx.Client(timeout=float(timeout_sec)) as client:
try:
r0 = post_with_retry(client, url=url, headers=headers, payload=payload_openai_multi)
except Exception as e:
return {"ok": False, "error": f"http request failed: {type(e).__name__}: {e}", "backend_shape": "openai-multi"}
if r0 is not None:
if r0.status_code >= 400:
err_last = f"http {r0.status_code}: {r0.text[:500]}"
else:
parsed, ok = _ok_response(r0)
if ok and isinstance(parsed, dict):
return {
"ok": True,
"text": str(parsed.get("text") or ""),
"images": list(parsed.get("images") or []),
"backend_shape": "openai-multi",
"input_kind": input_kind if input_kind else ["data_url"],
}
try:
r1 = post_with_retry(client, url=url, headers=headers, payload=payload_openai_single)
except Exception as e:
return {
"ok": False,
"error": err_last or f"openai-single request failed: {type(e).__name__}: {e}",
"backend_shape": "openai-single-failed",
}
if r1.status_code >= 400:
return {
"ok": False,
"error": err_last or f"http {r1.status_code}: {r1.text[:500]}",
"backend_shape": "openai-single-failed",
}
try:
obj1 = r1.json()
except Exception:
return {"ok": False, "error": f"non-json response: {r1.text[:500]}", "backend_shape": "openai-single"}
text, out_images = extract_text_and_images(obj1 if isinstance(obj1, dict) else {})
out: dict[str, Any] = {
"ok": bool(str(text or "").strip()),
"text": text,
"images": out_images,
"backend_shape": "openai-single",
"input_kind": input_kind[-1:] if input_kind else ["data_url"],
}
if not str(text or "").strip():
out["error"] = "empty assistant text after openai-single"
return out
__all__ = [
"VISION_DESCRIBE_PROMPT_ZH",
"VISION_OCR_EXTRACT_PROMPT_ZH",
"send_ocr_image_messages",
"vision_llm_backend_status",
]

91
svc/llm/replay_policy.py Normal file
View file

@ -0,0 +1,91 @@
"""Transcript replay policy for OpenAI-compatible Chat Completions.
Inspired by Oclaw (MIT) `src/agents/transcript-policy.ts` defaults for
`openai-completions`: enable strict tool-call-id sanitization so proxies and
multi-provider histories do not break on id format / length.
See OCLAW_MIT_LICENSE.txt in this package.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from svc.llm.tool_call_id import DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN
@dataclass(frozen=True)
class ReplayPolicy:
"""Controls pre-request normalization of chat messages for tool-use flow."""
enabled: bool
sanitize_tool_call_ids: bool
repair_tool_pairing: bool
max_tool_call_id_len: int
def _env_bool(name: str, default: bool) -> bool:
raw = str(os.getenv(name) or "").strip().lower()
if raw in ("0", "false", "no", "off"):
return False
if raw in ("1", "true", "yes", "on"):
return True
return default
def resolve_replay_policy(
base_url: str | None,
model_id: str | None,
*,
llm_profile_mode: str | None = None,
) -> ReplayPolicy:
"""Resolve replay policy for OpenAI-compatible chat completions.
When enabled (default): sanitize assistant/tool ids before every request.
This matches oclaw's strict OpenAI-compatible replay defaults.
"""
del base_url, model_id, llm_profile_mode # reserved for provider-specific overrides
if not _env_bool("AIA_REPLAY_POLICY_ENABLED", True):
return ReplayPolicy(
enabled=False,
sanitize_tool_call_ids=False,
repair_tool_pairing=False,
max_tool_call_id_len=DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN,
)
max_len = DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN
raw_ml = str(os.getenv("AIA_TOOL_CALL_ID_MAX_LEN") or "").strip()
if raw_ml.isdigit():
max_len = max(8, min(int(raw_ml), 128))
repair = _env_bool("AIA_REPLAY_REPAIR_TOOL_PAIRING", True)
return ReplayPolicy(
enabled=True,
sanitize_tool_call_ids=True,
repair_tool_pairing=repair,
max_tool_call_id_len=max_len,
)
def apply_replay_policy_to_messages(
messages: list[dict[str, Any]],
policy: ReplayPolicy,
) -> list[dict[str, Any]]:
"""Apply repair + id sanitization in order."""
if not policy.enabled or not messages:
return messages
from svc.llm.tool_call_id import (
repair_orphan_tool_messages,
rewrite_openai_chat_messages_tool_ids,
)
out = messages
if policy.repair_tool_pairing:
out = repair_orphan_tool_messages(out)
if policy.sanitize_tool_call_ids:
out = rewrite_openai_chat_messages_tool_ids(out, max_len=policy.max_tool_call_id_len)
return out

160
svc/llm/tool_call_id.py Normal file
View file

@ -0,0 +1,160 @@
"""OpenAI-compatible tool_call_id / tool result id rewriting.
Inspired by Oclaw (MIT) `src/agents/tool-call-id.ts`: sanitize ids to strict
alphanumeric form and keep assistant.tool_calls[].id paired with role=tool
tool_call_id in encounter order.
See OCLAW_MIT_LICENSE.txt in this package.
"""
from __future__ import annotations
import hashlib
import re
from collections import deque
from typing import Any, Literal
ToolCallIdMode = Literal["strict", "strict9"]
# OpenAI Chat Completions: tool_calls[].id commonly capped ~40 chars on some gateways.
DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN = 40
def _short_hash(text: str, length: int = 8) -> str:
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:length]
def sanitize_tool_call_id(raw: str | None, *, mode: ToolCallIdMode = "strict") -> str:
"""Strip to [a-zA-Z0-9] only; empty input gets a deterministic placeholder."""
if not raw or not isinstance(raw, str):
return "sanitizedtoolid" if mode == "strict" else "defaultid"
s = raw.strip()
if not s:
return "sanitizedtoolid" if mode == "strict" else "defaultid"
alnum = re.sub(r"[^a-zA-Z0-9]", "", s)
if mode == "strict9":
if len(alnum) >= 9:
return alnum[:9]
if alnum:
return _short_hash(alnum, 9)
return _short_hash("sanitized", 9)
return alnum if alnum else "sanitizedtoolid"
def _make_unique(seed: str, used: set[str], *, max_len: int) -> str:
base = sanitize_tool_call_id(seed, mode="strict")[:max_len]
if base and base not in used:
used.add(base)
return base
h = _short_hash(seed, 8)
for i in range(1000):
cand = f"{base[: max(0, max_len - len(h) - 1)]}{h}"[:max_len]
if i > 0:
suf = f"x{i}"
cand = f"{base[: max(0, max_len - len(suf) - len(h))]}{suf}{h}"[:max_len]
if cand not in used:
used.add(cand)
return cand
cand = _short_hash(f"{seed}:{id(used)}", max_len)[:max_len]
used.add(cand)
return cand
def rewrite_openai_chat_messages_tool_ids(
messages: list[dict[str, Any]],
*,
max_len: int = DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN,
) -> list[dict[str, Any]]:
"""Rewrite assistant tool_calls ids and matching tool tool_call_id/call_id.
Uses a per-raw-id queue (oclaw-style) so repeated raw ids map to distinct
new ids in order, and tool results consume the queue FIFO.
"""
if not messages:
return messages
pending: dict[str, deque[str]] = {}
used: set[str] = set()
def _alloc_for_raw(raw: str) -> str:
raw = str(raw or "").strip()
seed = raw if raw else "empty"
return _make_unique(seed, used, max_len=max_len)
out: list[dict[str, Any]] = []
for m in messages:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
if role == "assistant" and m.get("tool_calls"):
mm = dict(m)
tcs = mm.get("tool_calls")
if isinstance(tcs, list):
new_tcs: list[dict[str, Any]] = []
for tc in tcs:
if not isinstance(tc, dict):
new_tcs.append(tc)
continue
raw_id = str(tc.get("id") or "").strip()
new_id = _alloc_for_raw(raw_id)
q = pending.setdefault(raw_id, deque())
q.append(new_id)
new_tcs.append({**tc, "id": new_id})
mm["tool_calls"] = new_tcs
out.append(mm)
continue
if role == "tool":
mm = dict(m)
raw_ref = (
str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
)
new_ref: str | None = None
if raw_ref and raw_ref in pending and pending[raw_ref]:
new_ref = pending[raw_ref].popleft()
elif raw_ref:
# Orphan or ordering mismatch: allocate fresh id (best-effort pairing)
new_ref = _alloc_for_raw(raw_ref)
if new_ref:
mm["tool_call_id"] = new_ref
# Keep call_id in sync when both present (some gateways expect call_id)
if "call_id" in mm:
mm["call_id"] = new_ref
else:
mm.pop("tool_call_id", None)
mm.pop("call_id", None)
out.append(mm)
continue
out.append(dict(m) if isinstance(m, dict) else m)
return out
def repair_orphan_tool_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop tool_call_id/call_id from tool rows that reference no prior assistant tool_calls id."""
allowed: set[str] = set()
for m in messages:
if not isinstance(m, dict):
continue
if str(m.get("role") or "") != "assistant":
continue
tcs = m.get("tool_calls")
if not isinstance(tcs, list):
continue
for tc in tcs:
if isinstance(tc, dict) and tc.get("id"):
allowed.add(str(tc.get("id")))
out: list[dict[str, Any]] = []
for m in messages:
if not isinstance(m, dict):
out.append(m)
continue
if str(m.get("role") or "") != "tool":
out.append(m)
continue
mm = dict(m)
tid = str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
if tid and tid not in allowed:
mm.pop("tool_call_id", None)
mm.pop("call_id", None)
out.append(mm)
return out

205
svc/llm/tool_schema.py Normal file
View file

@ -0,0 +1,205 @@
from __future__ import annotations
import json
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# Full minimal object schema for ``function.parameters`` (wire / shrink / tier_minimal).
# A bare ``{type, additionalProperties}`` alone has led strict gateways to emit or validate
# ``required: null`` / missing array fields — always send explicit ``properties`` + ``required``.
_MIN_TOOL_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": True,
}
_COMBO_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"})
def _schema_type_tags(t: Any) -> set[str]:
if t is None:
return set()
if isinstance(t, list):
return {str(x).strip().lower() for x in t if x is not None and str(x).strip()}
s = str(t).strip().lower()
return {s} if s else set()
def complete_json_schema_for_openai_tools(obj: Any) -> Any:
"""Recursively ensure JSON-schema fragments match what strict OpenAI-compat gateways expect.
This is **structural completion** for our wire path (tier/shrink/SDK), not a general MCP fix-all:
every ``type: object`` node gets explicit ``properties`` + ``required`` (list); every
``type: array`` gets ``items`` (object). Combo keywords that must be arrays and are ``null``
are dropped; list elements that are ``null`` are removed.
"""
if isinstance(obj, dict):
out: dict[str, Any] = {}
for k, v in obj.items():
key = str(k)
if key in _COMBO_KEYS and v is None:
if key in ("allOf", "prefixItems"):
out[key] = []
continue
out[key] = complete_json_schema_for_openai_tools(v)
for combo in _COMBO_KEYS:
if combo not in out:
continue
val = out[combo]
if not isinstance(val, list):
continue
cleaned = [complete_json_schema_for_openai_tools(x) for x in val if x is not None]
if combo in ("anyOf", "oneOf") and not cleaned:
out.pop(combo, None)
else:
out[combo] = cleaned
if isinstance(out.get("properties"), dict):
t0 = out.get("type")
if t0 is None or (isinstance(t0, str) and not str(t0).strip()):
out["type"] = "object"
tags = _schema_type_tags(out.get("type"))
if "object" in tags:
if not isinstance(out.get("properties"), dict):
out["properties"] = {}
r = out.get("required")
if r is None or not isinstance(r, list):
out["required"] = []
if "array" in tags:
if out.get("items") is None:
out["items"] = {}
elif isinstance(out.get("items"), dict):
out["items"] = complete_json_schema_for_openai_tools(out["items"])
return out
if isinstance(obj, list):
return [complete_json_schema_for_openai_tools(x) for x in obj]
return obj
def complete_openai_tools_wire_parameters(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Apply :func:`complete_json_schema_for_openai_tools` to each function's ``parameters``."""
out: list[dict[str, Any]] = []
for ent in tools or []:
if not isinstance(ent, dict):
continue
if str(ent.get("type") or "") != "function":
out.append(ent)
continue
fn = ent.get("function")
if not isinstance(fn, dict):
out.append(ent)
continue
params = fn.get("parameters")
if not isinstance(params, dict):
row = dict(ent)
row["function"] = {**dict(fn), "parameters": dict(_MIN_TOOL_PARAMETERS)}
out.append(row)
continue
row = dict(ent)
row["function"] = {**dict(fn), "parameters": complete_json_schema_for_openai_tools(dict(params))}
out.append(row)
return out
def openai_tools_json_byte_length(tools: list[dict[str, Any]]) -> int:
try:
return len(json.dumps(tools, ensure_ascii=False, default=str))
except Exception:
return 0
def shrink_openai_tools_payload_for_api(tools: list[dict[str, Any]], *, max_json_bytes: int) -> list[dict[str, Any]]:
"""Shrink OpenAI ``tools`` JSON for strict OpenAI-compatible gateways."""
if not tools or max_json_bytes <= 0:
return tools
if openai_tools_json_byte_length(tools) <= max_json_bytes:
return tools
flat: list[dict[str, Any]] = []
for t in tools:
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
desc = str(fn.get("description") or "")
flat.append({"type": "function", "function": {"name": name, "description": desc, "parameters": dict(_MIN_TOOL_PARAMETERS)}})
if not flat:
return tools
def _serialized_with_desc_cap(cap: int) -> str:
trimmed: list[dict[str, Any]] = []
for x in flat:
fn0 = x["function"]
trimmed.append(
{
"type": "function",
"function": {
"name": fn0["name"],
"description": (fn0.get("description") or "")[: max(0, cap)],
"parameters": fn0["parameters"],
},
}
)
return json.dumps(trimmed, ensure_ascii=False, default=str)
lo, hi = 0, min(8000, max_json_bytes)
best_cap = 0
while lo <= hi:
mid = (lo + hi) // 2
s = _serialized_with_desc_cap(mid)
if len(s) <= max_json_bytes:
best_cap = mid
lo = mid + 1
else:
hi = mid - 1
out_obj: list[dict[str, Any]] = json.loads(_serialized_with_desc_cap(best_cap))
blob = json.dumps(out_obj, ensure_ascii=False, default=str)
dropped = 0
while len(out_obj) > 8 and len(blob) > max_json_bytes:
out_obj.pop()
dropped += 1
blob = json.dumps(out_obj, ensure_ascii=False, default=str)
logger.warning(
"Shrunk OpenAI tools[] for API size limits: count=%d max_json_bytes=%d desc_cap=%d dropped=%d final_bytes=%d",
len(out_obj),
max_json_bytes,
best_cap,
dropped,
len(blob),
)
return out_obj
def default_max_openai_tools_json_bytes(base_url: str | None) -> int | None:
raw = str(os.getenv("AIA_OPENAI_TOOLS_MAX_JSON_CHARS") or "").strip()
if raw.isdigit():
return max(2000, int(raw))
u = (base_url or "").lower()
if "dashscope.aliyuncs.com" in u:
return 28000
if str(os.getenv("AIA_SHRINK_OPENAI_TOOLS") or "").strip().lower() in ("1", "true", "yes", "on"):
mx = str(os.getenv("AIA_SHRINK_OPENAI_TOOLS_MAX_JSON") or "28000").strip()
return max(2000, int(mx)) if mx.isdigit() else 28000
return None
__all__ = [
"MIN_OPENAI_FUNCTION_PARAMETERS",
"complete_json_schema_for_openai_tools",
"complete_openai_tools_wire_parameters",
"default_max_openai_tools_json_bytes",
"openai_tools_json_byte_length",
"shrink_openai_tools_payload_for_api",
]
MIN_OPENAI_FUNCTION_PARAMETERS = _MIN_TOOL_PARAMETERS

787
svc/llm/tool_wire_policy.py Normal file
View file

@ -0,0 +1,787 @@
"""Graduated OpenAI ``tools[]`` shaping + usage-aware omission / per-tool admin policies.
Uses ``tool_log`` aggregates, ``app_setting`` for admin config / per-tool levels / penalty state.
Wire payload differs from local :class:`~src.tools.base.ToolSpec`; validation still uses full schemas.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from svc.llm.tool_schema import MIN_OPENAI_FUNCTION_PARAMETERS, complete_openai_tools_wire_parameters
logger = logging.getLogger(__name__)
SETTINGS_KEY_PENALTY_STATE = "mcp_tool_wire_penalty_state"
SETTINGS_KEY_TOOL_POLICIES = "mcp_tool_wire_tool_policies"
SETTINGS_KEY_ADMIN_CONFIG = "mcp_tool_wire_admin_config"
# Role-scoped (per expert) overrides. Shape:
# - policies: { "<role>": { "mcp__server__tool": <int level> } }
# - penalty: { "<role>": { "mcp__server__tool": <penalty state dict> } }
SETTINGS_KEY_PENALTY_STATE_BY_ROLE = "mcp_tool_wire_penalty_state_by_role"
SETTINGS_KEY_TOOL_POLICIES_BY_ROLE = "mcp_tool_wire_tool_policies_by_role"
SETTINGS_KEY_ROLE_MODE_BY_ROLE = "mcp_tool_wire_role_mode_by_role"
_MIN_PARAMETERS: dict[str, Any] = dict(MIN_OPENAI_FUNCTION_PARAMETERS)
def _env_prefixed(name_suffix: str, default: str = "") -> str:
raw = str(os.getenv(f"AIA_{name_suffix}") or "").strip()
if raw:
return raw
raw = str(os.getenv(f"OPS_{name_suffix}") or "").strip()
if raw:
return raw
return str(default).strip()
def wire_policy_enabled(base_url: str | None) -> bool:
raw = _env_prefixed("MCP_WIRE_USAGE_POLICY", "").lower()
if raw in ("1", "true", "yes", "on"):
return True
if raw in ("0", "false", "no", "off"):
return False
u = (base_url or "").lower()
return "dashscope.aliyuncs.com" in u
def penalty_disabled() -> bool:
return _env_prefixed("MCP_WIRE_PENALTY_DISABLE", "").lower() in ("1", "true", "yes", "on")
def _admin_defaults_from_env() -> dict[str, Any]:
return {
"wire_policy": "inherit",
"top_n_full": int(_env_prefixed("MCP_WIRE_TOP_N_FULL", "20") or "20"),
"stale_hours": float(_env_prefixed("MCP_WIRE_STALE_HOURS", "3") or "3"),
"penalty_minutes": float(_env_prefixed("MCP_WIRE_PENALTY_MINUTES", "30") or "30"),
"medium_rank_start": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_START", "21") or "21"),
"medium_rank_end": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_END", "50") or "50"),
"medium_desc_chars": int(_env_prefixed("MCP_WIRE_MEDIUM_DESC_CHARS", "520") or "520"),
"minimal_desc_cap": int(_env_prefixed("MCP_WIRE_MINIMAL_DESC_CAP", "80") or "80"),
"penalty_disable": False,
}
def load_merged_admin_config(store: Any) -> dict[str, Any]:
out = _admin_defaults_from_env()
try:
raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG)
if not raw:
return out
d = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(d, dict):
for k, v in d.items():
if k in out or k == "wire_policy":
out[k] = v
except Exception:
pass
pd = out.get("penalty_disable")
if isinstance(pd, str):
out["penalty_disable"] = pd.strip().lower() in ("1", "true", "yes", "on")
return out
def load_tool_policies_dict(store: Any) -> dict[str, int]:
try:
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES) or "{}"
d = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
return {}
if not isinstance(d, dict):
return {}
out: dict[str, int] = {}
for k, v in d.items():
nm = str(k or "").strip()
if not nm.startswith("mcp__"):
continue
try:
n = int(v)
except (TypeError, ValueError):
continue
if n == 9999:
out[nm] = 9999
elif n <= 0:
out[nm] = 0
else:
out[nm] = min(n, 9998)
return out
def load_tool_policies_dict_for_role(store: Any, *, role: str | None) -> dict[str, int]:
"""Role-scoped policies; falls back to global if absent/unparseable."""
r = str(role or "").strip().lower()
if not r:
return load_tool_policies_dict(store)
try:
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "{}"
outer = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
outer = {}
if not isinstance(outer, dict):
return load_tool_policies_dict(store)
inner = outer.get(r)
if not isinstance(inner, dict):
return load_tool_policies_dict(store)
# Reuse same coercion rules as global loader.
out: dict[str, int] = {}
for k, v in inner.items():
nm = str(k or "").strip()
if not nm.startswith("mcp__"):
continue
try:
n = int(v)
except (TypeError, ValueError):
continue
if n == 9999:
out[nm] = 9999
elif n <= 0:
out[nm] = 0
else:
out[nm] = min(n, 9998)
return out
def load_penalty_state_for_role(store: Any, *, role: str | None) -> dict[str, Any]:
"""Role-scoped penalty state; falls back to global if absent/unparseable."""
r = str(role or "").strip().lower()
if not r:
raw_pen = store.get_setting(SETTINGS_KEY_PENALTY_STATE) or "{}"
try:
pen = json.loads(raw_pen) if isinstance(raw_pen, str) else {}
except Exception:
pen = {}
if not isinstance(pen, dict):
pen = {}
return migrate_legacy_penalty_store(pen)
try:
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
outer = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
outer = {}
if not isinstance(outer, dict):
return load_penalty_state_for_role(store, role=None)
inner = outer.get(r)
if not isinstance(inner, dict):
return load_penalty_state_for_role(store, role=None)
return migrate_legacy_penalty_store(inner)
def _persist_penalty_state_for_role(store: Any, *, role: str | None, penalty_state: dict[str, Any]) -> None:
r = str(role or "").strip().lower()
clean: dict[str, Any] = {}
for k, v in (penalty_state or {}).items():
if v is None:
continue
if isinstance(v, dict):
clean[str(k)] = v
if not r:
store.set_setting(SETTINGS_KEY_PENALTY_STATE, json.dumps(clean, ensure_ascii=False))
return
try:
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
outer = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
outer = {}
if not isinstance(outer, dict):
outer = {}
outer[r] = clean
store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(outer, ensure_ascii=False))
def wire_graduation_effective(base_url: str | None, admin: dict[str, Any]) -> bool:
mode = str(admin.get("wire_policy") or "inherit").strip().lower()
if mode in ("always", "on", "true", "1", "yes"):
return True
if mode in ("never", "off", "false", "0", "no"):
return False
return wire_policy_enabled(base_url)
def penalty_effective_disabled(admin: dict[str, Any]) -> bool:
if bool(admin.get("penalty_disable")):
return True
return penalty_disabled()
def migrate_legacy_penalty_store(raw: dict[str, Any]) -> dict[str, Any]:
"""Older rows used ``omit_until`` without ``phase``; add ``kind`` for global tier."""
out: dict[str, Any] = {}
for k, v in (raw or {}).items():
if not isinstance(v, dict):
continue
v2 = dict(v)
if str(v2.get("phase") or "") in ("active", "done"):
if "kind" not in v2:
v2["kind"] = "d"
out[str(k)] = v2
continue
ou_s = str(v2.get("omit_until") or "")
ws = str(v2.get("wave_ts") or "")
ou = _parse_iso(ou_s)
if ou_s and ou:
if _utc_now() < ou:
v2 = {"phase": "active", "omit_until": ou_s, "wave_ts": ws, "kind": v2.get("kind") or "d"}
else:
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
elif ws:
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
else:
continue
out[str(k)] = v2
return out
def _parse_iso(ts: str) -> datetime | None:
s = str(ts or "").strip()
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
except Exception:
return None
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _wire_json_size(tools: list[dict[str, Any]]) -> int:
try:
return len(json.dumps(tools, ensure_ascii=False, default=str))
except Exception:
return 0
def _strip_nested_descriptions(obj: Any) -> Any:
if isinstance(obj, dict):
out: dict[str, Any] = {}
for k, v in obj.items():
if str(k) == "description":
continue
out[str(k)] = _strip_nested_descriptions(v)
return out
if isinstance(obj, list):
return [_strip_nested_descriptions(x) for x in obj]
return obj
def _tier_medium(fn: dict[str, Any], *, cfg: dict[str, Any]) -> dict[str, Any]:
mid_desc_len = int(cfg.get("medium_desc_chars") or 520)
mid_desc_len = max(80, min(mid_desc_len, 4000))
desc = str(fn.get("description") or "")[:mid_desc_len]
params = fn.get("parameters")
if isinstance(params, dict):
params_out = dict(_strip_nested_descriptions(params))
else:
params_out = dict(_MIN_PARAMETERS)
return {"name": fn.get("name"), "description": desc, "parameters": params_out}
def _tier_minimal(fn: dict[str, Any], *, desc_cap: int) -> dict[str, Any]:
desc = str(fn.get("description") or "")[: max(0, desc_cap)]
return {"name": fn.get("name"), "description": desc, "parameters": dict(_MIN_PARAMETERS)}
def _clone_tool_entry(t: dict[str, Any]) -> dict[str, Any]:
try:
return json.loads(json.dumps(t, ensure_ascii=False, default=str))
except Exception:
return dict(t)
def _is_builtin_tool_name(name: str) -> bool:
return not str(name).startswith("mcp__")
def _normalize_role_mode(v: Any) -> str:
s = str(v or "").strip().lower()
if s in {"unrestricted", "forbidden", "restricted"}:
return s
return "restricted"
def load_role_mode_for_role(store: Any, *, role: str | None) -> str:
r = str(role or "").strip().lower()
if not r:
return "restricted"
try:
raw = store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "{}"
obj = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
obj = {}
if not isinstance(obj, dict):
return "restricted"
return _normalize_role_mode(obj.get(r))
def _is_stale_hours(last_ts_iso: str | None, stale_hours: float) -> bool:
if not last_ts_iso:
return False
dt = _parse_iso(last_ts_iso)
if dt is None:
return False
return (_utc_now() - dt).total_seconds() > stale_hours * 3600.0
def _idle_minutes_exceeded(last_ts_iso: str | None, idle_minutes: float) -> bool:
if not last_ts_iso:
return False
dt = _parse_iso(last_ts_iso)
if dt is None:
return False
return (_utc_now() - dt).total_seconds() > idle_minutes * 60.0
def _wave_penalty_omit(
name: str,
last_ts: str | None,
idle_hit: bool,
penalty_minutes: float,
st_nm: Any,
out_penalty_updates: dict[str, Any],
*,
kind: str,
) -> bool:
pw = last_ts or ""
mins = max(1.0, min(float(penalty_minutes), 24 * 60))
if not idle_hit:
return False
prev = out_penalty_updates.get(name)
if isinstance(prev, dict) and str(prev.get("kind") or "") != str(kind):
out_penalty_updates.pop(name, None)
st_nm = None
else:
st_nm = prev
omit = False
if isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "active":
ou = _parse_iso(str(st_nm.get("omit_until") or ""))
if ou and _utc_now() < ou:
omit = True
else:
out_penalty_updates[name] = {"phase": "done", "wave_ts": pw, "kind": str(kind)}
elif isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "done" and str(st_nm.get("wave_ts") or "") == pw:
pass
else:
until = (_utc_now() + timedelta(minutes=mins)).isoformat()
out_penalty_updates[name] = {"phase": "active", "omit_until": until, "wave_ts": pw, "kind": str(kind)}
omit = True
return omit
def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], policies: dict[str, int]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for entry in tools:
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
out.append(entry)
continue
fn = entry.get("function")
if not isinstance(fn, dict):
out.append(entry)
continue
name = str(fn.get("name") or "").strip()
if not name or _is_builtin_tool_name(name):
out.append(entry)
continue
if policies.get(name) == 9999:
continue
out.append(entry)
return out
def prepare_openai_tools_for_llm_api(
tools: list[dict[str, Any]],
*,
base_url: str | None,
max_json_bytes: int | None,
store: Any | None = None,
role: str | None = None,
) -> list[dict[str, Any]]:
"""Build wire ``tools[]``: optional graduated policy + byte cap."""
def _fallback_shrink(raw: list[dict[str, Any]], cap: int) -> list[dict[str, Any]]:
from svc.llm.tool_schema import shrink_openai_tools_payload_for_api
return shrink_openai_tools_payload_for_api(raw, max_json_bytes=cap)
if not tools:
return tools
tools = complete_openai_tools_wire_parameters(tools)
def _finalize(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Re-apply completion after tier/shrink/squeeze (those paths emit fresh ``parameters`` dicts)."""
return complete_openai_tools_wire_parameters(result)
policies: dict[str, int] = {}
admin: dict[str, Any] = _admin_defaults_from_env()
role_mode = "restricted"
try:
if store is None:
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
store = SqliteStore(db_path())
# Role-scoped policies if configured; otherwise fall back to global.
policies = load_tool_policies_dict_for_role(store, role=str(role or "").strip().lower() or None)
admin = load_merged_admin_config(store)
role_mode = load_role_mode_for_role(store, role=str(role or "").strip().lower() or None)
except Exception as exc:
logger.warning("tool_wire_policy: load admin/policies (%s)", exc)
def _filter_mcp_out(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for entry in raw:
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
out.append(entry)
continue
fn = entry.get("function")
if not isinstance(fn, dict):
out.append(entry)
continue
nm = str(fn.get("name") or "").strip()
if nm.startswith("mcp__"):
continue
out.append(entry)
return out
tools_pass1 = filter_permanent_ban_mcp_tools(tools, policies)
if role_mode == "forbidden":
out = _filter_mcp_out(tools)
if max_json_bytes is not None and max_json_bytes > 0:
return _finalize(_fallback_shrink(out, max_json_bytes))
return _finalize(out)
if role_mode == "unrestricted":
if max_json_bytes is not None and max_json_bytes > 0:
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
return _finalize(tools_pass1)
if not wire_graduation_effective(base_url, admin):
if max_json_bytes is not None and max_json_bytes > 0:
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
return _finalize(tools_pass1)
top_n = int(admin.get("top_n_full") or 20)
top_n = max(3, min(top_n, 80))
stale_h = float(admin.get("stale_hours") or 3)
stale_h = max(0.25, min(stale_h, 720.0))
penalty_min_global = float(admin.get("penalty_minutes") or 30)
penalty_min_global = max(1.0, min(penalty_min_global, 24 * 60))
medium_floor = int(admin.get("medium_rank_start") or 21)
medium_ceiling = int(admin.get("medium_rank_end") or 50)
if medium_floor > medium_ceiling:
medium_floor, medium_ceiling = medium_ceiling, medium_floor
usage_map: dict[str, dict[str, Any]] = {}
penalty_state: dict[str, Any] = {}
try:
usage_map = store.list_mcp_tool_aggregate_usage()
penalty_state = load_penalty_state_for_role(store, role=str(role or "").strip().lower() or None)
except Exception as exc:
logger.warning("tool_wire_policy: usage store unavailable (%s); fallback shrink only", exc)
if max_json_bytes is not None and max_json_bytes > 0:
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
return _finalize(tools_pass1)
ranked = sorted(
usage_map.items(),
key=lambda kv: (-int(kv[1].get("count") or 0), str(kv[1].get("last_ts") or "")),
)
rank_by_name = {nm: idx + 1 for idx, (nm, _) in enumerate(ranked)}
top_full = {nm for nm, _ in ranked[:top_n]}
out_penalty_updates: dict[str, Any] = dict(penalty_state)
omit_set: set[str] = set()
for nm in list(out_penalty_updates.keys()):
meta = usage_map.get(nm)
cur_ts = str(meta.get("last_ts") or "") if meta else ""
st = out_penalty_updates.get(nm)
if not isinstance(st, dict):
continue
w_ts = str(st.get("wave_ts") or "")
if w_ts and cur_ts and w_ts != cur_ts:
out_penalty_updates.pop(nm, None)
pen_glob = penalty_effective_disabled(admin)
built: list[dict[str, Any]] = []
for entry in tools_pass1:
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
built.append(entry)
continue
fn = entry.get("function")
if not isinstance(fn, dict):
built.append(entry)
continue
name = str(fn.get("name") or "").strip()
if not name:
built.append(entry)
continue
if _is_builtin_tool_name(name):
built.append(_clone_tool_entry(entry))
continue
usage = usage_map.get(name) or {}
last_ts = str(usage.get("last_ts") or "") or None
rk = rank_by_name.get(name)
policy_lv = policies.get(name)
st_nm = out_penalty_updates.get(name)
# policy_lv semantics (punishment only):
# - None: inherit global graduation + idle penalty.
# - 0: exempt this tool from idle penalty (but still participates in graduation tiers).
# - 1..9998: per-tool idle/penalty tuning (N * 10 minutes) + still participates in graduation tiers.
# - 9999: permanent ban (filtered earlier in filter_permanent_ban_mcp_tools).
omit = False
if policy_lv == 0:
omit = False
elif policy_lv is not None and policy_lv >= 1:
idle_m = float(policy_lv) * 10.0
pen_m = float(policy_lv) * 10.0
idle_hit = _idle_minutes_exceeded(last_ts, idle_m)
kind = f"L{int(policy_lv)}"
if not pen_glob:
omit = _wave_penalty_omit(
name, last_ts, idle_hit, pen_m, st_nm, out_penalty_updates, kind=kind
)
else:
idle_hit = _is_stale_hours(last_ts, stale_h)
exempt = name in top_full
if not pen_glob and idle_hit and not exempt:
omit = _wave_penalty_omit(
name,
last_ts,
idle_hit,
penalty_min_global,
st_nm,
out_penalty_updates,
kind="d",
)
if omit:
omit_set.add(name)
continue
tier = "minimal"
if name in top_full:
tier = "full"
elif rk is not None and medium_floor <= rk <= medium_ceiling:
tier = "medium"
else:
tier = "minimal"
if tier == "full":
built.append(_clone_tool_entry(entry))
elif tier == "medium":
mf = _tier_medium(fn, cfg=admin)
built.append({"type": "function", "function": mf})
else:
cap0 = int(admin.get("minimal_desc_cap") or 80)
cap0 = max(0, min(cap0, 2000))
mf = _tier_minimal(fn, desc_cap=cap0)
built.append({"type": "function", "function": mf})
try:
_persist_penalty_state_for_role(
store,
role=str(role or "").strip().lower() or None,
penalty_state=out_penalty_updates,
)
except Exception as exc:
logger.warning("tool_wire_policy: persist penalty state failed: %s", exc)
if omit_set:
logger.info(
"tool_wire_policy: omitted %d MCP tools during penalty window: %s",
len(omit_set),
sorted(omit_set)[:12],
)
if max_json_bytes is not None and max_json_bytes > 0 and _wire_json_size(built) > max_json_bytes:
return _finalize(_squeeze_to_budget(built, max_json_bytes, admin=admin))
return _finalize(built)
def _squeeze_to_budget(
built: list[dict[str, Any]], max_json_bytes: int, *, admin: dict[str, Any]
) -> list[dict[str, Any]]:
work = json.loads(json.dumps(built, ensure_ascii=False, default=str))
def sz() -> int:
return _wire_json_size(work)
if sz() <= max_json_bytes:
return work
for i, ent in enumerate(work):
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
ps = fn.get("parameters")
if isinstance(ps, dict) and len(json.dumps(ps, ensure_ascii=False)) > 400:
nm = str(fn.get("name") or "")
if nm.startswith("mcp__"):
cap = int(admin.get("minimal_desc_cap") or 80)
work[i] = {
"type": "function",
"function": _tier_minimal(fn, desc_cap=max(0, cap)),
}
if sz() <= max_json_bytes:
return work
for cap in (60, 40, 24, 12, 8, 4, 0):
for i, ent in enumerate(work):
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
nm = str(fn.get("name") or "")
if nm.startswith("mcp__"):
work[i] = {"type": "function", "function": _tier_minimal(fn, desc_cap=cap)}
if sz() <= max_json_bytes:
return work
idxs = [i for i, e in enumerate(work) if str((e.get("function") or {}).get("name") or "").startswith("mcp__")]
for i in reversed(idxs):
if sz() <= max_json_bytes:
break
work.pop(i)
if sz() > max_json_bytes:
from svc.llm.tool_schema import shrink_openai_tools_payload_for_api
work = shrink_openai_tools_payload_for_api(work, max_json_bytes=max_json_bytes)
return work
def penalty_row_status(
*,
wire_name: str,
policy_level: int | None,
penalty: dict[str, Any] | None,
last_ts: str | None,
) -> dict[str, Any]:
"""Human-readable row for Admin UI."""
if policy_level is None:
return {
"phase": "inherit",
"omit_until": None,
"unblock_hint": "未配置:走全局线侧与闲置惩罚(与显式写入 0 不同)",
"kind": None,
}
if policy_level == 9999:
return {
"phase": "permanent_ban",
"omit_until": None,
"unblock_hint": "永久不上送(需将策略改为非 9999)",
"kind": None,
}
if policy_level == 0:
return {
"phase": "exempt",
"omit_until": None,
"unblock_hint": "策略 0:不参与闲置惩罚(仍可能受线侧分层/压缩影响)",
"kind": None,
}
st = penalty.get(wire_name) if isinstance(penalty, dict) else None
if not isinstance(st, dict):
return {"phase": "none", "omit_until": None, "unblock_hint": "-", "kind": None}
ph = str(st.get("phase") or "")
ou = str(st.get("omit_until") or "")
kd = str(st.get("kind") or "")
if ph == "active" and ou:
dt = _parse_iso(ou)
hint = f"惩罚中,预计解封(omit_until): {ou}"
if dt and _utc_now() >= dt:
hint = "惩罚窗口应已结束;下次请求会写入 done"
return {"phase": "active", "omit_until": ou, "unblock_hint": hint, "kind": kd}
if ph == "done":
return {
"phase": "done",
"omit_until": None,
"unblock_hint": "本轮已服完刑;再次闲置达到阈值后会重新惩罚",
"kind": kd,
}
return {"phase": ph or "unknown", "omit_until": ou or None, "unblock_hint": "-", "kind": kd or None}
def build_tool_wire_snapshot(store: Any, *, role: str | None = None) -> dict[str, Any]:
"""Aggregate MCP install list + usage + policies for Admin GET."""
from runtime.tools.mcp.registry import McpRegistry
admin = load_merged_admin_config(store)
role_norm = str(role or "").strip().lower() or None
role_mode = load_role_mode_for_role(store, role=role_norm)
policies = load_tool_policies_dict_for_role(store, role=role_norm)
pen = load_penalty_state_for_role(store, role=role_norm)
usage = store.list_mcp_tool_aggregate_usage()
servers = McpRegistry(store).list_servers(enabled_only=False)
tools_out: list[dict[str, Any]] = []
for s in servers:
sid = str(s.get("server_id") or "").strip()
if not sid:
continue
for t in store.list_mcp_server_tools(server_id=sid):
short = str(t.get("tool_name") or "").strip()
if not short:
continue
wire = f"mcp__{sid}__{short}"
agg = usage.get(wire) or {}
in_db = wire in policies
lv = int(policies[wire]) if in_db else None
st = penalty_row_status(
wire_name=wire,
policy_level=lv,
penalty=pen,
last_ts=str(agg.get("last_ts") or "") or None,
)
tools_out.append(
{
"server_id": sid,
"mcp_tool_name": short,
"wire_name": wire,
"policy_level": lv,
"policy_in_db": in_db,
"count": int(agg.get("count") or 0),
"last_ts": str(agg.get("last_ts") or ""),
"penalty": st,
"raw_penalty": pen.get(wire) if isinstance(pen.get(wire), dict) else None,
}
)
tools_out.sort(key=lambda x: (x["server_id"], x["wire_name"]))
return {
"ok": True,
"role": role_norm or "",
"role_mode": role_mode,
"config": admin,
"policies": policies,
"penalty_state": pen,
"tools": tools_out,
}
__all__ = [
"SETTINGS_KEY_PENALTY_STATE",
"SETTINGS_KEY_TOOL_POLICIES",
"SETTINGS_KEY_ADMIN_CONFIG",
"SETTINGS_KEY_PENALTY_STATE_BY_ROLE",
"SETTINGS_KEY_TOOL_POLICIES_BY_ROLE",
"SETTINGS_KEY_ROLE_MODE_BY_ROLE",
"build_tool_wire_snapshot",
"filter_permanent_ban_mcp_tools",
"load_merged_admin_config",
"load_tool_policies_dict",
"load_tool_policies_dict_for_role",
"load_penalty_state_for_role",
"load_role_mode_for_role",
"migrate_legacy_penalty_store",
"penalty_row_status",
"prepare_openai_tools_for_llm_api",
"wire_policy_enabled",
]

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"]

View file

@ -0,0 +1,466 @@
"""DashScope / Model Studio **text-to-video** and **image-to-video** (async: create task → poll).
Used by the **video** specialist (Admin Chat + orchestration) to bypass the generic LLM tool loop,
analogous to :mod:`svc.llm.image_legacy_client` for the image specialist.
**Text-to-video:** ``input`` only ``prompt`` (plus optional ``audio_url`` / ``negative_prompt`` for some models).
Use a **t2v** model id.
**Image-to-video (Wan 2.6 and earlier):** ``input.img_url`` — HTTPS or ``data:image/...;base64,...``.
**Image-to-video (Wan 2.7 general API):** ``input.media``, e.g.
``[{"type": "first_frame", "url": "<https or data url>"}]``.
Do **not** send ``img_url`` for that path (see Alibaba *image-to-video-general* API reference).
Override with ``AIA_VIDEO_I2V_INPUT_STYLE=media|img_url``; default is **auto** (``wan2.7`` in model id → ``media``, else ``img_url``).
HTTP contract (region of ``base_url`` must match the API key):
- ``POST {root}/api/v1/services/aigc/video-generation/video-synthesis`` with header
``X-DashScope-Async: enable``
- ``GET {root}/api/v1/tasks/{task_id}`` until ``output.task_status`` is terminal
References:
- https://www.alibabacloud.com/help/en/model-studio/text-to-video-api-reference/
- https://www.alibabacloud.com/help/en/model-studio/image-to-video-api-reference/
- https://www.alibabacloud.com/help/en/model-studio/image-to-video-general-api-reference/
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from typing import Any, Callable, Literal
import httpx
from svc.files.attachment_assets import AttachmentAssetStore
from svc.llm.image_http_common import (
compress_data_url_image,
dashscope_multimodal_http_ok,
download_http_url_bytes,
is_data_url,
join_url,
post_with_retry,
)
VIDEO_SPECIALIST_DEFAULT_PROMPT_ZH = "请根据描述生成一段短视频,画面稳定、主体清晰。"
_DEFAULT_SYNTHESIS_PATH = "api/v1/services/aigc/video-generation/video-synthesis"
def _truthy_env(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
def env_video_expert_api_key() -> str:
return (os.getenv("AIA_VIDEO_EXPERT_API_KEY") or "").strip()
def env_video_expert_base_url() -> str:
return (os.getenv("AIA_VIDEO_EXPERT_BASE_URL") or "").strip()
def env_video_expert_model() -> str:
return (os.getenv("AIA_VIDEO_EXPERT_MODEL") or "").strip()
def _effective_video_model_for_request(model_name: str, *, img_url: str | None) -> str:
"""DashScope Wan: **text-to-video** ids use ``*-t2v-*``; **image-to-video** needs ``*-i2v-*``.
Chat profiles often configure only a **t2v** model; if we still submit ``input.img_url``, the gateway
may largely follow the prompt and ignore the photograph (looks like unrelated output). When a first
frame is present we therefore coerce ``-t2v-`` → ``-i2v-`` unless disabled or overridden.
"""
m = (model_name or "").strip()
if not m:
return m
if not str(img_url or "").strip():
return m
if _truthy_env("AIA_VIDEO_EXPERT_DISABLE_I2V_MODEL_COERCION"):
return m
override = (os.getenv("AIA_VIDEO_EXPERT_I2V_MODEL") or "").strip()
if override:
return override
if "-t2v-" in m:
coerced = m.replace("-t2v-", "-i2v-", 1)
if coerced != m:
return coerced
low = m.lower().rstrip()
if low.endswith("-t2v"):
coerced = m[: -len("-t2v")] + "-i2v"
if coerced != m:
return coerced
return m
def _i2v_first_frame_input_style(*, model_name: str) -> Literal["img_url", "media"]:
"""How to pass the first-frame image: legacy ``img_url`` vs Wan 2.7 ``media`` array."""
raw = (os.getenv("AIA_VIDEO_I2V_INPUT_STYLE") or "").strip().lower()
if raw in ("media", "wan27", "general"):
return "media"
if raw in ("img_url", "legacy", "wan26"):
return "img_url"
ml = (model_name or "").strip().lower()
return "media" if "wan2.7" in ml else "img_url"
def env_video_expert_synthesis_path() -> str:
raw = (os.getenv("AIA_VIDEO_EXPERT_SYNTHESIS_PATH") or "").strip().lstrip("/")
return raw or _DEFAULT_SYNTHESIS_PATH
def _poll_interval_sec() -> float:
raw = (os.getenv("AIA_VIDEO_EXPERT_POLL_INTERVAL_SEC") or "").strip()
if not raw:
return 15.0
try:
return max(3.0, min(float(raw), 120.0))
except ValueError:
return 15.0
def _max_wait_sec() -> float:
raw = (os.getenv("AIA_VIDEO_EXPERT_MAX_WAIT_SEC") or "").strip()
if not raw:
return 900.0
try:
return max(30.0, min(float(raw), 3600.0))
except ValueError:
return 900.0
def dashscope_api_root_from_base_url(base_url: str) -> str:
"""Strip OpenAI-compatible suffixes so native DashScope paths can be joined."""
b = (base_url or "").strip().rstrip("/")
if not b:
return ""
root = re.sub(r"/compatible-mode/v\d+(?:/.*)?$", "", b, flags=re.IGNORECASE).rstrip("/")
if not root or root == b:
root = re.sub(r"/compatible-mode/?$", "", b, flags=re.IGNORECASE).rstrip("/")
return root or b
def _parameters_extra_from_env() -> dict[str, Any]:
raw = (os.getenv("AIA_VIDEO_EXPERT_PARAMETERS_EXTRA") or "").strip()
if not raw:
return {}
try:
v = json.loads(raw)
return v if isinstance(v, dict) else {}
except Exception:
return {}
def _input_extra_from_env() -> dict[str, Any]:
raw = (os.getenv("AIA_VIDEO_EXPERT_INPUT_EXTRA") or "").strip()
if not raw:
return {}
try:
v = json.loads(raw)
return v if isinstance(v, dict) else {}
except Exception:
return {}
def _dashscope_video_env_parameters() -> dict[str, Any]:
out: dict[str, Any] = {}
size = (os.getenv("DASHSCOPE_VIDEO_SIZE") or "").strip()
if size:
out["size"] = size
raw_dur = (os.getenv("DASHSCOPE_VIDEO_DURATION") or "").strip()
if raw_dur.isdigit():
out["duration"] = max(1, min(int(raw_dur), 600))
pe = (os.getenv("DASHSCOPE_VIDEO_PROMPT_EXTEND") or "").strip().lower()
if pe in ("1", "true", "yes", "on"):
out["prompt_extend"] = True
elif pe in ("0", "false", "no", "off"):
out["prompt_extend"] = False
st = (os.getenv("DASHSCOPE_VIDEO_SHOT_TYPE") or "").strip()
if st:
out["shot_type"] = st
wm = (os.getenv("DASHSCOPE_VIDEO_WATERMARK") or "").strip().lower()
if wm in ("1", "true", "yes", "on"):
out["watermark"] = True
elif wm in ("0", "false", "no", "off"):
out["watermark"] = False
seed = (os.getenv("DASHSCOPE_VIDEO_SEED") or "").strip()
if seed.isdigit():
out["seed"] = max(0, min(int(seed), 2_147_483_647))
return out
def _dashscope_video_env_input() -> dict[str, Any]:
out: dict[str, Any] = {}
neg = os.getenv("DASHSCOPE_VIDEO_NEGATIVE_PROMPT")
if neg is not None and str(neg).strip():
out["negative_prompt"] = str(neg)
audio = (os.getenv("DASHSCOPE_VIDEO_AUDIO_URL") or "").strip()
if audio:
out["audio_url"] = audio
return out
def _extract_output(body: dict[str, Any]) -> dict[str, Any]:
out = body.get("output")
return out if isinstance(out, dict) else {}
def _stderr_debug_video(url: str, payload: dict[str, Any]) -> None:
if not _truthy_env("AIA_VIDEO_EXPERT_DEBUG_PRINT_PAYLOAD"):
return
try:
txt = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
sys.stderr.write(f"\n[oclaw video_generation] POST {url}\n{txt}\n\n")
sys.stderr.flush()
except Exception:
pass
def send_video_generation_request(
*,
prompt: str,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
img_url: str | None = None,
timeout_submit_sec: float = 120.0,
on_progress: Callable[[str], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> dict[str, Any]:
"""Create an async video task and poll until terminal state or timeout.
When ``img_url`` is set (HTTPS URL or data URL), it becomes the first-frame reference: for Wan **2.7**
i2v models this is sent as ``input.media`` with ``type: first_frame`` and ``url``; for older i2v models
as ``input.img_url``. Caller image overrides any overlapping keys from ``AIA_VIDEO_EXPERT_INPUT_EXTRA`` after merge
(style selected by model id and ``AIA_VIDEO_I2V_INPUT_STYLE``).
"""
resolved_key = (api_key or env_video_expert_api_key()).strip()
raw_base = (base_url or env_video_expert_base_url()).strip()
root = dashscope_api_root_from_base_url(raw_base)
model_name = ((model or "").strip() or env_video_expert_model()).strip()
if not resolved_key or not root:
return {
"ok": False,
"error": "missing AIA_VIDEO_EXPERT_API_KEY or AIA_VIDEO_EXPERT_BASE_URL (or pass api_key and base_url)",
}
if not model_name:
return {
"ok": False,
"error": "missing model (chosen profile/model=… or set AIA_VIDEO_EXPERT_MODEL)",
}
prompt_plain = str(prompt or "").strip()
if not prompt_plain:
prompt_plain = VIDEO_SPECIALIST_DEFAULT_PROMPT_ZH
model_name = _effective_video_model_for_request(model_name, img_url=img_url)
path = env_video_expert_synthesis_path()
url_submit = join_url(root, path)
input_body: dict[str, Any] = {
**_input_extra_from_env(),
**_dashscope_video_env_input(),
"prompt": prompt_plain,
}
iu = str(img_url or "").strip()
if iu:
if is_data_url(iu):
iu = compress_data_url_image(iu)
_style = _i2v_first_frame_input_style(model_name=model_name)
if _style == "media":
input_body.pop("img_url", None)
first = {"type": "first_frame", "url": iu}
prev = input_body.get("media")
rest: list[dict[str, Any]] = []
if isinstance(prev, list):
for item in prev:
if isinstance(item, dict) and str(item.get("type") or "").strip().lower() != "first_frame":
rest.append(dict(item))
input_body["media"] = [first, *rest]
else:
input_body.pop("media", None)
input_body["img_url"] = iu
parameters: dict[str, Any] = {
**_dashscope_video_env_parameters(),
**_parameters_extra_from_env(),
}
payload = {"model": model_name, "input": input_body, "parameters": parameters}
headers = {
"Authorization": f"Bearer {resolved_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
}
_stderr_debug_video(url_submit, payload)
submit_timeout = httpx.Timeout(max(30.0, float(timeout_submit_sec)), connect=45.0)
with httpx.Client(timeout=submit_timeout, follow_redirects=True) as client:
try:
r = post_with_retry(client, url=url_submit, headers=headers, payload=payload)
except Exception as e:
return {"ok": False, "error": f"submit failed: {type(e).__name__}: {e}"}
if r.status_code >= 400:
return {"ok": False, "error": f"submit http {r.status_code}: {r.text[:800]}"}
try:
body = r.json()
except Exception:
return {"ok": False, "error": f"submit non-json: {r.text[:500]}"}
if not isinstance(body, dict):
return {"ok": False, "error": "submit response is not a JSON object"}
ok0, err0 = dashscope_multimodal_http_ok(body)
if not ok0:
return {"ok": False, "error": err0 or "submit rejected by provider"}
out0 = _extract_output(body)
task_id = str(out0.get("task_id") or "").strip()
st0 = str(out0.get("task_status") or "").strip().upper()
v0 = str(out0.get("video_url") or "").strip()
if st0 == "SUCCEEDED" and v0.startswith(("http://", "https://")):
return {"ok": True, "video_urls": [v0], "task_id": task_id or None}
if st0 in {"FAILED", "UNKNOWN"}:
msg = str(out0.get("message") or out0.get("msg") or "").strip()
code = str(out0.get("code") or "").strip()
bit = f"{code}: {msg}".strip(": ").strip() or st0
return {"ok": False, "error": bit, "task_id": task_id or None}
if not task_id:
return {"ok": False, "error": "submit response missing output.task_id", "raw": body}
poll_interval = _poll_interval_sec()
max_wait = _max_wait_sec()
deadline = time.monotonic() + max_wait
task_url = join_url(root, f"api/v1/tasks/{task_id}")
headers_get = {"Authorization": f"Bearer {resolved_key}"}
poll_timeout = httpx.Timeout(120.0, connect=45.0)
with httpx.Client(timeout=poll_timeout, follow_redirects=True) as client:
while time.monotonic() < deadline:
if should_stop and should_stop():
return {"ok": False, "error": "stopped", "task_id": task_id}
try:
gr = client.get(task_url, headers=headers_get)
except Exception as e:
return {"ok": False, "error": f"poll failed: {type(e).__name__}: {e}", "task_id": task_id}
if gr.status_code >= 400:
return {
"ok": False,
"error": f"poll http {gr.status_code}: {gr.text[:800]}",
"task_id": task_id,
}
try:
tb = gr.json()
except Exception:
return {"ok": False, "error": f"poll non-json: {gr.text[:500]}", "task_id": task_id}
if not isinstance(tb, dict):
return {"ok": False, "error": "poll response not a JSON object", "task_id": task_id}
okp, errp = dashscope_multimodal_http_ok(tb)
if not okp:
return {"ok": False, "error": errp or "poll rejected", "task_id": task_id}
tout = _extract_output(tb)
st = str(tout.get("task_status") or "").strip().upper()
vurl = str(tout.get("video_url") or "").strip()
if st == "SUCCEEDED" and vurl.startswith(("http://", "https://")):
return {"ok": True, "video_urls": [vurl], "task_id": task_id}
if st in {"FAILED", "UNKNOWN", "CANCELED"}:
msg = str(tout.get("message") or tout.get("msg") or "").strip()
code = str(tout.get("code") or "").strip()
bit = f"{code}: {msg}".strip(": ").strip() or st
return {"ok": False, "error": bit, "task_id": task_id}
if on_progress and st in {"PENDING", "RUNNING"}:
on_progress(f"oclaw: video task {st.lower()}…")
time.sleep(poll_interval)
return {"ok": False, "error": f"poll timeout after {int(max_wait)}s", "task_id": task_id}
def materialize_video_output_attachments(
video_urls: Any,
*,
max_videos: int = 1,
) -> list[dict[str, Any]]:
"""Download remote MP4 (etc.) into ``video_ref`` rows; on failure fall back to best-effort metadata."""
cap = max(1, min(int(max_videos), 4))
produced: list[dict[str, Any]] = []
if not isinstance(video_urls, list):
return produced
urls = [str(u).strip() for u in video_urls if str(u).strip().startswith(("http://", "https://"))]
store = AttachmentAssetStore()
ua = "Mozilla/5.0 (compatible; oclaw-video-expert/1.0; +https://github.com/)"
for idx, u in enumerate(urls[:cap], start=1):
try:
blob, ctype = download_http_url_bytes(u, user_agent=ua)
if not blob:
raise ValueError("empty body")
mime = (ctype.split(";", 1)[0].strip() if ctype else "") or "video/mp4"
ext = ".mp4"
if mime == "video/webm":
ext = ".webm"
elif mime in ("video/quicktime", "video/mov"):
ext = ".mov"
meta = store.save_bytes(blob, filename=f"video-output-{idx}{ext}", mime=mime)
produced.append(
{
"type": "video_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
}
)
except Exception:
produced.append(
{
"type": "video_ref",
"name": f"video-output-{idx}.mp4",
"mime": "video/mp4",
"url": u,
}
)
return produced
def legacy_video_turn_bundle(resp: dict[str, Any]) -> tuple[bool, str, list[dict[str, Any]]]:
ok = bool(resp.get("ok"))
text = str(resp.get("text") or "").strip()
if not ok:
err = str(resp.get("error") or "").strip()
return False, f"Video generation failed: {err or 'unknown error'}", []
urls = resp.get("video_urls")
if not isinstance(urls, list):
urls = []
atts = materialize_video_output_attachments(urls, max_videos=1)
if atts:
return True, text, atts
if text:
return True, text, []
return False, "Video specialist failed: empty response from provider.", []
def legacy_video_assistant_body_with_placeholder(
*,
lang: str | None,
body_text: str,
produced: list[dict[str, Any]] | None,
) -> str:
if str(body_text or "").strip():
return str(body_text or "")
if produced:
return (
"Generated video (see attachment below)."
if str(lang or "").startswith("en")
else "已生成视频(见下方附件)。"
)
return str(body_text or "")
__all__ = [
"VIDEO_SPECIALIST_DEFAULT_PROMPT_ZH",
"dashscope_api_root_from_base_url",
"env_video_expert_api_key",
"env_video_expert_base_url",
"env_video_expert_model",
"legacy_video_assistant_body_with_placeholder",
"legacy_video_turn_bundle",
"materialize_video_output_attachments",
"send_video_generation_request",
]

View file

File diff suppressed because it is too large Load diff