mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 12:23:18 +08:00
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:
parent
7941b27f58
commit
420abac9f1
405 changed files with 1316 additions and 1345 deletions
1
svc/__init__.py
Normal file
1
svc/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Shared services layer (LLM transports, persistence, configuration, files)."""
|
||||
0
svc/config/__init__.py
Normal file
0
svc/config/__init__.py
Normal file
90
svc/config/bootstrap_env.py
Normal file
90
svc/config/bootstrap_env.py
Normal 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
25
svc/config/passwords.py
Normal 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
200
svc/config/paths.py
Normal 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"]
|
||||
57
svc/config/runtime_paths.py
Normal file
57
svc/config/runtime_paths.py
Normal 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",
|
||||
]
|
||||
|
||||
77
svc/embeddings/embedding_client.py
Normal file
77
svc/embeddings/embedding_client.py
Normal 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
0
svc/files/__init__.py
Normal file
202
svc/files/archive_processor.py
Normal file
202
svc/files/archive_processor.py
Normal 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",
|
||||
]
|
||||
|
||||
378
svc/files/attachment_assets.py
Normal file
378
svc/files/attachment_assets.py
Normal 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",
|
||||
]
|
||||
|
||||
776
svc/files/file_attachments.py
Normal file
776
svc/files/file_attachments.py
Normal 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"]
|
||||
66
svc/files/session_export.py
Normal file
66
svc/files/session_export.py
Normal 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"]
|
||||
732
svc/files/tabular_attachment_store.py
Normal file
732
svc/files/tabular_attachment_store.py
Normal 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",
|
||||
]
|
||||
|
||||
197
svc/files/text_attachment_store.py
Normal file
197
svc/files/text_attachment_store.py
Normal 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",
|
||||
]
|
||||
|
||||
87
svc/integrations/cc_mini_vendor.py
Normal file
87
svc/integrations/cc_mini_vendor.py
Normal 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,
|
||||
)
|
||||
64
svc/integrations/wecom_client.py
Normal file
64
svc/integrations/wecom_client.py
Normal 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
27
svc/llm/MIT_LICENSE.txt
Normal 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
1
svc/llm/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# svc.llm package
|
||||
69
svc/llm/chat_models.py
Normal file
69
svc/llm/chat_models.py
Normal 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",
|
||||
]
|
||||
|
||||
550
svc/llm/image_http_common.py
Normal file
550
svc/llm/image_http_common.py
Normal 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",
|
||||
]
|
||||
733
svc/llm/image_legacy_client.py
Normal file
733
svc/llm/image_legacy_client.py
Normal 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
200
svc/llm/image_ocr_client.py
Normal 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
91
svc/llm/replay_policy.py
Normal 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
160
svc/llm/tool_call_id.py
Normal 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
205
svc/llm/tool_schema.py
Normal 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
787
svc/llm/tool_wire_policy.py
Normal 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",
|
||||
]
|
||||
1
svc/llm/transports/__init__.py
Normal file
1
svc/llm/transports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# svc.llm.transports package
|
||||
204
svc/llm/transports/anthropic_messages.py
Normal file
204
svc/llm/transports/anthropic_messages.py
Normal 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"]
|
||||
|
||||
63
svc/llm/transports/base.py
Normal file
63
svc/llm/transports/base.py
Normal 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())
|
||||
|
||||
267
svc/llm/transports/google_gemini_sse.py
Normal file
267
svc/llm/transports/google_gemini_sse.py
Normal 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"]
|
||||
|
||||
728
svc/llm/transports/openai_chat_completions.py
Normal file
728
svc/llm/transports/openai_chat_completions.py
Normal 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
|
||||
|
||||
811
svc/llm/transports/openai_responses.py
Normal file
811
svc/llm/transports/openai_responses.py
Normal 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"]
|
||||
|
||||
144
svc/llm/transports/simple.py
Normal file
144
svc/llm/transports/simple.py
Normal 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"]
|
||||
|
||||
466
svc/llm/video_generation_client.py
Normal file
466
svc/llm/video_generation_client.py
Normal 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",
|
||||
]
|
||||
0
svc/persistence/__init__.py
Normal file
0
svc/persistence/__init__.py
Normal file
5917
svc/persistence/sqlite_store.py
Normal file
5917
svc/persistence/sqlite_store.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue