398 lines
19 KiB
Python
398 lines
19 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from agentui.providers.adapters.base import ( # [ProviderAdapter](agentui/providers/adapters/base.py:10)
|
||
ProviderAdapter,
|
||
insert_items,
|
||
split_pos_spec,
|
||
)
|
||
|
||
|
||
def _try_json(s: str) -> Any: # [_try_json()](agentui/providers/adapters/openai.py:16)
|
||
"""
|
||
Парсит JSON из строки. Пермиссивный режим и двукратная распаковка строк, как в старой логике.
|
||
Возвращает dict/list/примитив или None при неудаче.
|
||
"""
|
||
try:
|
||
obj = json.loads(s)
|
||
except Exception:
|
||
try:
|
||
obj = json.loads(s, strict=False) # type: ignore[call-arg]
|
||
except Exception:
|
||
return None
|
||
# Если это строка, которая сама похожа на JSON — пробуем распаковать до 2 раз
|
||
for _ in range(2):
|
||
if isinstance(obj, str):
|
||
st = obj.strip()
|
||
if (st.startswith("{") and st.endswith("}")) or (st.startswith("[") and st.endswith("]")):
|
||
try:
|
||
obj = json.loads(st)
|
||
continue
|
||
except Exception:
|
||
break
|
||
break
|
||
return obj
|
||
|
||
|
||
class OpenAIAdapter(ProviderAdapter): # [OpenAIAdapter.__init__()](agentui/providers/adapters/openai.py:39)
|
||
name = "openai"
|
||
|
||
# --- Дефолты HTTP ---
|
||
def default_base_url(self) -> str:
|
||
return "https://api.openai.com"
|
||
|
||
def default_endpoint(self, model: str) -> str:
|
||
return "/v1/chat/completions"
|
||
|
||
# --- PROMPT: построение провайдерных структур ---
|
||
|
||
def blocks_struct_for_template(
|
||
self,
|
||
unified_messages: List[Dict[str, Any]],
|
||
context: Dict[str, Any],
|
||
node_config: Dict[str, Any],
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Совместимо с веткой provider=='openai' из [ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:1958).
|
||
"""
|
||
def _map(m: Dict[str, Any]) -> Dict[str, Any]:
|
||
c = m.get("content")
|
||
if isinstance(c, list):
|
||
parts = []
|
||
for p in c:
|
||
if isinstance(p, dict) and p.get("type") == "text":
|
||
parts.append({"type": "text", "text": str(p.get("text") or "")})
|
||
elif isinstance(p, dict) and p.get("type") in {"image_url", "image"}:
|
||
url = str(p.get("url") or "")
|
||
parts.append({"type": "image_url", "image_url": {"url": url}})
|
||
return {"role": m.get("role", "user"), "content": parts}
|
||
return {"role": m.get("role", "user"), "content": str(c or "")}
|
||
|
||
# system_text — склейка всех system-блоков (только текст, без картинок)
|
||
sys_text = "\n\n".join(
|
||
[
|
||
str(m.get("content") or "")
|
||
if not isinstance(m.get("content"), list)
|
||
else "\n".join(
|
||
[str(p.get("text") or "") for p in m.get("content") if isinstance(p, dict) and p.get("type") == "text"]
|
||
)
|
||
for m in (unified_messages or [])
|
||
if m.get("role") == "system"
|
||
]
|
||
).strip()
|
||
|
||
return {
|
||
"messages": [_map(m) for m in (unified_messages or [])],
|
||
"system_text": sys_text,
|
||
}
|
||
|
||
def normalize_segment(self, x: Any) -> List[Dict[str, Any]]:
|
||
"""
|
||
Совместимо с [_as_openai_messages()](agentui/pipeline/executor.py:2451).
|
||
- Поддерживает dict with messages (openai)
|
||
- Поддерживает dict/list в стиле Gemini.contents (склейка текстов частей)
|
||
- Поддерживает list openai-like messages (нормализация parts)
|
||
- Строки/прочее упаковываются как один user message
|
||
"""
|
||
msgs: List[Dict[str, Any]] = []
|
||
try:
|
||
# Dict inputs
|
||
if isinstance(x, dict):
|
||
if isinstance(x.get("messages"), list):
|
||
return list(x.get("messages") or [])
|
||
if isinstance(x.get("contents"), list):
|
||
# Gemini -> OpenAI (text-only join)
|
||
for c in (x.get("contents") or []):
|
||
if not isinstance(c, dict):
|
||
continue
|
||
role_raw = str(c.get("role") or "user")
|
||
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
|
||
parts = c.get("parts") or []
|
||
text = "\n".join(
|
||
[str(p.get("text")) for p in parts if isinstance(p, dict) and isinstance(p.get("text"), str)]
|
||
).strip()
|
||
msgs.append({"role": role, "content": text})
|
||
return msgs
|
||
|
||
# List inputs
|
||
if isinstance(x, list):
|
||
# Gemini contents list -> OpenAI messages
|
||
if all(isinstance(c, dict) and "parts" in c for c in x):
|
||
for c in x:
|
||
role_raw = str(c.get("role") or "user")
|
||
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
|
||
parts = c.get("parts") or []
|
||
text = "\n".join(
|
||
[str(p.get("text")) for p in parts if isinstance(p, dict) and isinstance(p.get("text"), str)]
|
||
).strip()
|
||
msgs.append({"role": role, "content": text})
|
||
return msgs
|
||
# OpenAI messages list already — normalize parts if needed
|
||
if all(isinstance(m, dict) and "content" in m for m in x):
|
||
out: List[Dict[str, Any]] = []
|
||
for m in x:
|
||
role = m.get("role", "user")
|
||
cont = m.get("content")
|
||
if isinstance(cont, str):
|
||
out.append({"role": role, "content": cont})
|
||
elif isinstance(cont, list):
|
||
parts2: List[Dict[str, Any]] = []
|
||
for p in cont:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
if p.get("type") == "text":
|
||
parts2.append({"type": "text", "text": str(p.get("text") or "")})
|
||
elif p.get("type") in {"image_url", "image"}:
|
||
url = ""
|
||
if isinstance(p.get("image_url"), dict):
|
||
url = str((p.get("image_url") or {}).get("url") or "")
|
||
elif "url" in p:
|
||
url = str(p.get("url") or "")
|
||
if url:
|
||
parts2.append({"type": "image_url", "image_url": {"url": url}})
|
||
out.append({"role": role, "content": parts2 if parts2 else ""})
|
||
return out
|
||
# Fallback: dump JSON as a single user message
|
||
return [{"role": "user", "content": json.dumps(x, ensure_ascii=False)}]
|
||
|
||
# Primitive inputs or embedded JSON string
|
||
if isinstance(x, str):
|
||
try_obj = _try_json(x)
|
||
if try_obj is not None:
|
||
return self.normalize_segment(try_obj)
|
||
return [{"role": "user", "content": x}]
|
||
return [{"role": "user", "content": json.dumps(x, ensure_ascii=False)}]
|
||
except Exception:
|
||
return [{"role": "user", "content": str(x)}]
|
||
|
||
def filter_items(self, arr: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""
|
||
Совместимо с [_filter_openai()](agentui/pipeline/executor.py:2801).
|
||
"""
|
||
out: List[Dict[str, Any]] = []
|
||
for m in (arr or []):
|
||
if not isinstance(m, dict):
|
||
continue
|
||
c = m.get("content")
|
||
if isinstance(c, str) and c.strip():
|
||
out.append({"role": m.get("role", "user"), "content": c})
|
||
elif isinstance(c, list):
|
||
parts = []
|
||
for p in c:
|
||
if isinstance(p, dict) and p.get("type") == "text":
|
||
txt = str(p.get("text") or "")
|
||
if txt.strip():
|
||
parts.append({"type": "text", "text": txt})
|
||
if parts:
|
||
out.append({"role": m.get("role", "user"), "content": parts})
|
||
return out
|
||
|
||
def extract_system_text_from_obj(self, x: Any, render_ctx: Dict[str, Any]) -> Optional[str]:
|
||
"""
|
||
Совместимо с [_extract_sys_text_from_obj()](agentui/pipeline/executor.py:2676).
|
||
Умеет читать:
|
||
- Gemini: systemInstruction.parts[].text
|
||
- Claude: top-level system (string/list of blocks)
|
||
- OpenAI: messages[*] with role=system (string content or parts[].text)
|
||
- List форматы: openai messages list и gemini contents list (в последнем случае смотрит incoming.json.systemInstruction)
|
||
"""
|
||
try:
|
||
# Dict objects
|
||
if isinstance(x, dict):
|
||
# Gemini systemInstruction
|
||
if "systemInstruction" in x:
|
||
si = x.get("systemInstruction")
|
||
|
||
def _parts_to_text(siobj: Any) -> str:
|
||
try:
|
||
parts = siobj.get("parts") or []
|
||
texts = [
|
||
str(p.get("text") or "")
|
||
for p in parts
|
||
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip()
|
||
]
|
||
return "\n".join([t for t in texts if t]).strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
if isinstance(si, dict):
|
||
t = _parts_to_text(si)
|
||
if t:
|
||
return t
|
||
if isinstance(si, list):
|
||
texts = []
|
||
for p in si:
|
||
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip():
|
||
texts.append(p.get("text").strip())
|
||
t = "\n".join(texts).strip()
|
||
if t:
|
||
return t
|
||
if isinstance(si, str) and si.strip():
|
||
return si.strip()
|
||
# Claude system (string or blocks)
|
||
if "system" in x and not ("messages" in x and isinstance(x.get("messages"), list)):
|
||
sysv = x.get("system")
|
||
if isinstance(sysv, str) and sysv.strip():
|
||
return sysv.strip()
|
||
if isinstance(sysv, list):
|
||
texts = [
|
||
str(b.get("text") or "")
|
||
for b in sysv
|
||
if isinstance(b, dict) and (b.get("type") == "text") and isinstance(b.get("text"), str) and b.get("text").strip()
|
||
]
|
||
t = "\n".join([t for t in texts if t]).strip()
|
||
if t:
|
||
return t
|
||
# OpenAI messages with role=system
|
||
if isinstance(x.get("messages"), list):
|
||
sys_msgs = []
|
||
for m in (x.get("messages") or []):
|
||
try:
|
||
if (str(m.get("role") or "").lower().strip() == "system"):
|
||
cont = m.get("content")
|
||
if isinstance(cont, str) and cont.strip():
|
||
sys_msgs.append(cont.strip())
|
||
elif isinstance(cont, list):
|
||
for p in cont:
|
||
if (
|
||
isinstance(p, dict)
|
||
and p.get("type") == "text"
|
||
and isinstance(p.get("text"), str)
|
||
and p.get("text").strip()
|
||
):
|
||
sys_msgs.append(p.get("text").strip())
|
||
except Exception:
|
||
continue
|
||
if sys_msgs:
|
||
return "\n\n".join(sys_msgs).strip()
|
||
|
||
# List objects
|
||
if isinstance(x, list):
|
||
# OpenAI messages list with role=system
|
||
if all(isinstance(m, dict) and "role" in m for m in x):
|
||
sys_msgs = []
|
||
for m in x:
|
||
try:
|
||
if (str(m.get("role") or "").lower().strip() == "system"):
|
||
cont = m.get("content")
|
||
if isinstance(cont, str) and cont.strip():
|
||
sys_msgs.append(cont.strip())
|
||
elif isinstance(cont, list):
|
||
for p in cont:
|
||
if (
|
||
isinstance(p, dict)
|
||
and p.get("type") == "text"
|
||
and isinstance(p.get("text"), str)
|
||
and p.get("text").strip()
|
||
):
|
||
sys_msgs.append(p.get("text").strip())
|
||
except Exception:
|
||
continue
|
||
if sys_msgs:
|
||
return "\n\n".join(sys_msgs).strip()
|
||
# Gemini 'contents' list: try to read systemInstruction from incoming JSON snapshot
|
||
if all(isinstance(c, dict) and "parts" in c for c in x):
|
||
try:
|
||
inc = (render_ctx.get("incoming") or {}).get("json") or {}
|
||
si = inc.get("systemInstruction")
|
||
if si is not None:
|
||
# Рекурсивно используем себя
|
||
return self.extract_system_text_from_obj({"systemInstruction": si}, render_ctx)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
def combine_segments(
|
||
self,
|
||
blocks_struct: Dict[str, Any],
|
||
pre_segments_raw: List[Dict[str, Any]],
|
||
raw_segs: List[str],
|
||
render_ctx: Dict[str, Any],
|
||
pre_var_paths: set[str],
|
||
render_template_simple_fn,
|
||
var_macro_fullmatch_re,
|
||
detect_vendor_fn,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Повторяет ветку provider=='openai' из prompt_combine в [ProviderCallNode.run()](agentui/pipeline/executor.py:2936).
|
||
"""
|
||
built: List[Dict[str, Any]] = []
|
||
sys_texts: List[str] = []
|
||
|
||
# 1) Пред‑сегменты (prompt_preprocess)
|
||
for _pre in (pre_segments_raw or []):
|
||
try:
|
||
_obj = _pre.get("obj")
|
||
items = self.normalize_segment(_obj)
|
||
items = self.filter_items(items)
|
||
built = insert_items(built, items, _pre.get("pos"))
|
||
try:
|
||
sx = self.extract_system_text_from_obj(_obj, render_ctx)
|
||
if isinstance(sx, str) and sx.strip():
|
||
sys_texts.append(sx.strip())
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# 2) Основные сегменты (prompt_combine)
|
||
for raw_seg in (raw_segs or []):
|
||
body_seg, pos_spec = split_pos_spec(raw_seg)
|
||
if body_seg == "[[PROMPT]]":
|
||
items = self.filter_items(list(blocks_struct.get("messages", []) or []))
|
||
built = insert_items(built, items, pos_spec)
|
||
continue
|
||
# Спрятать дубли plain [[VAR:path]] если уже вставляли этим путём в pre_var_overrides
|
||
m_pre = var_macro_fullmatch_re.fullmatch(body_seg)
|
||
if m_pre:
|
||
_p = (m_pre.group(1) or "").strip()
|
||
try:
|
||
if _p in pre_var_paths:
|
||
# Уже вставлено через prompt_preprocess с фильтрацией — пропускаем
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
resolved = render_template_simple_fn(body_seg, render_ctx, render_ctx.get("OUT") or {})
|
||
obj = _try_json(resolved)
|
||
# debug provider guess
|
||
try:
|
||
pg = detect_vendor_fn(obj if isinstance(obj, dict) else {})
|
||
print(f"DEBUG: prompt_combine seg provider_guess={pg} -> target=openai pos={pos_spec}")
|
||
except Exception:
|
||
pass
|
||
items = self.normalize_segment(obj if obj is not None else resolved)
|
||
items = self.filter_items(items)
|
||
built = insert_items(built, items, pos_spec)
|
||
try:
|
||
sx = self.extract_system_text_from_obj(obj, render_ctx) if obj is not None else None
|
||
if isinstance(sx, str) and sx.strip():
|
||
sys_texts.append(sx.strip())
|
||
except Exception:
|
||
pass
|
||
|
||
# Если ничего не собрали — берём исходные blocks
|
||
if not built:
|
||
built = self.filter_items(list(blocks_struct.get("messages", []) or []))
|
||
|
||
# Препендинг системных сообщений из sys_texts
|
||
if sys_texts:
|
||
sys_msgs = [{"role": "system", "content": s} for s in sys_texts if s]
|
||
if sys_msgs:
|
||
built = sys_msgs + built
|
||
|
||
# keep system_text for UI/debug
|
||
st0 = blocks_struct.get("system_text") or ""
|
||
st = "\n\n".join([t for t in [st0] + sys_texts if isinstance(t, str) and t.strip()])
|
||
return {"messages": built, "system_text": st}
|
||
|
||
def prompt_fragment(self, pm_struct: Dict[str, Any], node_config: Dict[str, Any]) -> str:
|
||
"""
|
||
Совместимо с веткой provider=='openai' в построении [[PROMPT]] из [ProviderCallNode.run()](agentui/pipeline/executor.py:3103).
|
||
"""
|
||
return '"messages": ' + json.dumps(pm_struct.get("messages", []), ensure_ascii=False) |