"""adapter 公共工具:messages 归一化、model 映射、system 软化、tool 历史拍扁。
模型目录来自 :mod:`app.upstream.models`(占位,用 scripts/probe_catalog.py 探测后填入),
本模块不再硬编码 catalog。
工具协议双通道:
- **模型输出**:``{...}``(宿主解析)
- **历史进上游**:``[tools]`` + 按 id 并列 call/result(见 :func:`format_tools_history_block`)
"""
from __future__ import annotations
import json
from typing import Any
from app.system_sanitizer import default_identity_system, remove_junk_lines, soften_system
from app.upstream import DefaultModelRegistry
_registry = DefaultModelRegistry()
def supported_models() -> list[dict[str, Any]]:
"""OpenAI 兼容的 /v1/models 列表。
2026-08-08:生图模型(IMAGE_MODEL_CATALOG,如 gpt-image-2 / flux-2-pro 等)一并
合并进 /v1/models——cherry 等客户端只认 /v1/models 做模型下拉,不查独立的
/v1/images/models;生图请求会走 /v1/images/generations 路由,与这里的 id 无冲突。
"""
models = [{"id": m["id"], "object": "model", "owned_by": m.get("owner", "unknown")}
for m in _registry.catalog()]
from app.upstream.models import IMAGE_MODEL_CATALOG # 延迟导入避免循环
for m in IMAGE_MODEL_CATALOG:
models.append({"id": m["id"], "object": "model", "owned_by": "anuma-image"})
return models
def normalize_model(model: str | None) -> str:
"""客户端传的 model 归一化为 catalog id;空或未知 → 默认。"""
return _registry.normalize(model)
def upstream_id_for(model_id: str) -> str | None:
"""catalog id → 上游真实模型标识(供 orchestrator/adapter 传给上游)。"""
return _registry.upstream_id_for(model_id)
def _lang_of(text: str | None) -> str:
"""简单语种检测:含 CJK → zh,否则 en(用于 system 软化包装语)。"""
if text and any("一" <= c <= "鿿" for c in text):
return "zh"
return "en"
def _thinking_text(block: dict[str, Any]) -> str | None:
"""从 Anthropic thinking / redacted_thinking block 提取文本。"""
if block.get("type") == "thinking":
t = block.get("thinking")
if isinstance(t, str) and t:
return f"\n{t}\n"
if block.get("type") == "redacted_thinking":
return ""
return None
def _reasoning_text(block: dict[str, Any]) -> str | None:
"""从 OpenAI Responses reasoning block 提取 summary 文本。"""
if block.get("type") != "reasoning":
return None
summary = block.get("summary") or []
texts: list[str] = []
for s in summary:
if isinstance(s, dict) and s.get("type") == "summary_text":
texts.append(s.get("text", ""))
text = "".join(texts)
return f"\n{text}\n" if text else None
def _coerce_tool_arguments(arguments: dict[str, Any] | Any) -> dict[str, Any]:
"""把 tool arguments 规范为 dict。"""
if isinstance(arguments, dict):
return arguments
if arguments is None:
return {}
if isinstance(arguments, str):
try:
parsed = json.loads(arguments)
return parsed if isinstance(parsed, dict) else {"value": arguments}
except (json.JSONDecodeError, ValueError):
return {"value": arguments} if arguments else {}
return {"value": arguments}
def format_tool_call_fence(
name: str,
arguments: dict[str, Any] | Any,
*,
call_id: str | None = None,
) -> str:
"""模型**输出**协议:单个 ``{...}``(宿主解析用,可带 id)。"""
args = _coerce_tool_arguments(arguments)
payload: dict[str, Any] = {"name": name or "", "arguments": args}
if call_id:
payload["id"] = call_id
return f"{json.dumps(payload, ensure_ascii=False)}"
def format_tools_history_block(entries: list[dict[str, Any]]) -> str:
"""历史上下文中的并行 tool 统一块(调用 + 结果按 id 并列)。
形态::
[tools]
[id1]
name: Bash
arguments: {"command": "ls"}
---
result:
AGENTS.md
[id2]
name: Read
arguments: {"path": "a.py"}
---
result:
print(1)
每条 entry:``id`` / ``name`` / ``arguments`` / ``result`` / ``is_error``。
"""
if not entries:
return ""
lines: list[str] = ["[tools]"]
for e in entries:
cid = str(e.get("id") or "?").strip() or "?"
lines.append(f"[{cid}]")
name = e.get("name")
if name:
lines.append(f"name: {name}")
if "arguments" in e and e.get("arguments") is not None:
args = _coerce_tool_arguments(e.get("arguments"))
lines.append(f"arguments: {json.dumps(args, ensure_ascii=False)}")
if "result" in e and e.get("result") is not None:
lines.append("---")
lines.append("result (error):" if e.get("is_error") else "result:")
body = str(e.get("result") if e.get("result") is not None else "")
lines.append(body if body else "(empty)")
lines.append("")
return "\n".join(lines).rstrip()
def format_tool_role_block(
content: str,
*,
call_id: str | None = None,
name: str | None = None,
is_error: bool = False,
) -> str:
"""单条 result 兼容入口 → 统一 ``[tools]`` 块。"""
return format_tools_history_block([{
"id": call_id or "?",
"name": name or "",
"result": content if content is not None else "",
"is_error": is_error,
}])
def _tool_result_body(block: dict[str, Any]) -> str:
"""Anthropic ``tool_result`` block 的 content → 纯文本。"""
raw = block.get("content")
if raw is None:
return ""
if isinstance(raw, str):
return raw
if isinstance(raw, list):
bits: list[str] = []
for c in raw:
if isinstance(c, dict):
t = c.get("type")
if t in ("text", "input_text", "output_text") or "text" in c:
bits.append(str(c.get("text", "")))
else:
bits.append(str(c))
return "\n".join(x for x in bits if x)
return str(raw)
def flatten_text(content: Any, *, include_tools: bool = False) -> str:
"""OpenAI/Anthropic content → 纯文本。
默认跳过 tool_use / tool_result(由 :func:`extract_user_prompt` 统一成 ``[tools]``)。
``include_tools=True`` 时才内联渲染(少见路径)。
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
out: list[str] = []
for c in content:
if isinstance(c, dict):
t = c.get("type")
if t in ("text", "input_text", "output_text"):
out.append(c.get("text", "") or "")
elif t in ("thinking", "redacted_thinking"):
cot = _thinking_text(c)
if cot:
out.append(cot)
elif t == "reasoning":
cot = _reasoning_text(c)
if cot:
out.append(cot)
elif t in ("tool_use", "function_call", "tool_result", "function_call_output"):
if not include_tools:
continue
if t == "tool_use":
out.append(format_tool_call_fence(
str(c.get("name") or ""),
c.get("input") or {},
call_id=c.get("id") or None,
))
elif t == "function_call":
out.append(format_tool_call_fence(
str(c.get("name") or ""),
c.get("arguments") or c.get("input") or {},
call_id=c.get("call_id") or c.get("id") or None,
))
elif t == "tool_result":
out.append(format_tool_role_block(
_tool_result_body(c),
call_id=c.get("tool_use_id") or c.get("id") or None,
name=c.get("name") or None,
is_error=bool(c.get("is_error")),
))
else:
out.append(format_tool_role_block(
str(
c.get("output")
if c.get("output") is not None
else c.get("content") or ""
),
call_id=c.get("call_id") or c.get("id") or None,
name=c.get("name") or None,
))
elif "text" in c:
out.append(str(c["text"]))
else:
out.append(str(c))
return "\n\n".join(x for x in out if x)
return str(content)
def _extract_call_entries(m: dict[str, Any]) -> list[dict[str, Any]]:
"""从 assistant 消息提取并行 tool 调用 entries(OpenAI tool_calls / content tool_use)。"""
entries: list[dict[str, Any]] = []
seen: set[str] = set()
def add(call_id: str | None, name: str, arguments: Any) -> None:
cid = str(call_id or f"call_{len(entries)}").strip() or f"call_{len(entries)}"
if cid in seen:
return
seen.add(cid)
entries.append({
"id": cid,
"name": name or "",
"arguments": _coerce_tool_arguments(arguments),
})
for tc in m.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
if not isinstance(fn, dict):
fn = {}
add(
tc.get("id") or tc.get("call_id"),
str(fn.get("name") or tc.get("name") or ""),
fn.get("arguments", tc.get("arguments", {})),
)
content = m.get("content")
if isinstance(content, list):
for c in content:
if not isinstance(c, dict):
continue
t = c.get("type")
if t == "tool_use":
add(c.get("id"), str(c.get("name") or ""), c.get("input") or {})
elif t == "function_call":
add(
c.get("call_id") or c.get("id"),
str(c.get("name") or ""),
c.get("arguments") or c.get("input") or {},
)
return entries
def _extract_result_entries_from_message(m: dict[str, Any]) -> list[dict[str, Any]]:
"""从 role=tool/function 或 user(content 含 tool_result) 提取 result entries。"""
role = (m.get("role") or "").lower()
entries: list[dict[str, Any]] = []
if role in ("tool", "function"):
entries.append({
"id": str(m.get("tool_call_id") or m.get("id") or "?"),
"name": str(m.get("name") or ""),
"result": flatten_text(m.get("content")),
"is_error": bool(m.get("is_error")),
})
return entries
content = m.get("content")
if not isinstance(content, list):
return entries
for c in content:
if not isinstance(c, dict):
continue
t = c.get("type")
if t == "tool_result":
entries.append({
"id": str(c.get("tool_use_id") or c.get("id") or "?"),
"name": str(c.get("name") or ""),
"result": _tool_result_body(c),
"is_error": bool(c.get("is_error")),
})
elif t == "function_call_output":
entries.append({
"id": str(c.get("call_id") or c.get("id") or "?"),
"name": str(c.get("name") or ""),
"result": str(
c.get("output") if c.get("output") is not None else c.get("content") or ""
),
"is_error": bool(c.get("is_error")),
})
return entries
def _user_text_without_tools(content: Any) -> str:
"""user content 去掉 tool_result 后的纯文本。"""
return flatten_text(content, include_tools=False)
def _content_has_tool_payload(content: Any) -> bool:
"""content 是否含 tool_result / function_call_output。"""
if not isinstance(content, list):
return False
return any(
isinstance(c, dict) and c.get("type") in ("tool_result", "function_call_output")
for c in content
)
def _merge_call_and_result_entries(
calls: list[dict[str, Any]],
results: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""按 id 把 result 并入 call;无匹配 call 的 result 单独追加。"""
by_id: dict[str, dict[str, Any]] = {}
order: list[str] = []
for c in calls:
cid = str(c.get("id") or "?")
if cid not in by_id:
order.append(cid)
by_id[cid] = dict(c)
else:
by_id[cid].update({k: v for k, v in c.items() if v not in (None, "")})
for r in results:
cid = str(r.get("id") or "?")
if cid not in by_id:
order.append(cid)
by_id[cid] = {"id": cid}
if r.get("name") and not by_id[cid].get("name"):
by_id[cid]["name"] = r["name"]
by_id[cid]["result"] = r.get("result") if r.get("result") is not None else ""
if r.get("is_error"):
by_id[cid]["is_error"] = True
return [by_id[cid] for cid in order]
def _has_nonempty_system(messages: list[dict[str, Any]]) -> bool:
"""是否存在非空 system 消息(空白 / 纯垃圾元数据行不算)。"""
for m in messages:
if m.get("role") != "system":
continue
content = remove_junk_lines(flatten_text(m.get("content")))
if content:
return True
return False
def extract_user_prompt(
messages: list[dict[str, Any]],
*,
model_id: str | None = None,
soften: bool | None = None,
) -> str:
"""把 messages 拍平成发给上游的单条用户消息(带角色前缀)。
角色标记:
- ``[system]`` / ``[user]`` / ``[assistant]``
- ``[tools]``:并行 tool 调用与结果统一块,按 id 分组(OpenAI / Anthropic / Responses 归一)
相邻 assistant call 与后续 tool / tool_result 会按 id 合并;call 与 result **都显示**。
模型**输出**仍用 ``...``。
tool 协议指令由 orchestrator 始终拼在本函数返回值**之前与之后**。
system 仅在 ``soften=True``(或配置 ``soften_system=true``)时软化;默认原样 ``[system]\\n...``。
"""
if soften is None:
from app.config import get_settings
soften = bool(get_settings().soften_system)
parts: list[str] = []
if model_id and not _has_nonempty_system(messages):
parts.append(default_identity_system(model_id=model_id))
i = 0
n = len(messages)
while i < n:
m = messages[i]
role = (m.get("role") or "user").lower()
reasoning = m.get("reasoning_content")
cot_prefix = (
f"\n{reasoning}\n\n\n"
if isinstance(reasoning, str) and reasoning else ""
)
if role == "system":
content = flatten_text(m.get("content"))
if soften:
body = soften_system(content, lang=_lang_of(content))
else:
body = remove_junk_lines(content) if content else ""
body = body.strip() if body else content.strip()
if body:
# 软化时已是柔和框架,不再包 [system];默认强制 [system] 标签
if soften:
parts.append(f"{cot_prefix}{body}")
else:
parts.append(f"{cot_prefix}[system]\n{body}")
i += 1
continue
if role == "assistant":
calls = _extract_call_entries(m)
text_body = flatten_text(m.get("content"), include_tools=False)
if calls:
results: list[dict[str, Any]] = []
j = i + 1
trailing_user_text = ""
while j < n:
mj = messages[j]
rj = (mj.get("role") or "").lower()
if rj == "assistant":
more = _extract_call_entries(mj)
more_text = flatten_text(mj.get("content"), include_tools=False)
if more and not (more_text or "").strip():
calls.extend(more)
j += 1
continue
break
if rj in ("tool", "function"):
results.extend(_extract_result_entries_from_message(mj))
j += 1
continue
if rj == "user" and _content_has_tool_payload(mj.get("content")):
results.extend(_extract_result_entries_from_message(mj))
trailing_user_text = _user_text_without_tools(mj.get("content"))
j += 1
break
break
entries = _merge_call_and_result_entries(calls, results)
if text_body:
parts.append(f"{cot_prefix}[assistant]\n{text_body}")
elif cot_prefix:
parts.append(cot_prefix.rstrip())
parts.append(format_tools_history_block(entries))
if trailing_user_text:
parts.append(f"[user]\n{trailing_user_text}")
i = j
continue
parts.append(f"{cot_prefix}[assistant]\n{text_body}")
i += 1
continue
if role in ("tool", "function"):
results = _extract_result_entries_from_message(m)
j = i + 1
while j < n and (messages[j].get("role") or "").lower() in ("tool", "function"):
results.extend(_extract_result_entries_from_message(messages[j]))
j += 1
block = format_tools_history_block(
_merge_call_and_result_entries([], results)
)
if block:
parts.append(f"{cot_prefix}{block}" if cot_prefix else block)
i = j
continue
if role == "user" and _content_has_tool_payload(m.get("content")):
results = _extract_result_entries_from_message(m)
user_text = _user_text_without_tools(m.get("content"))
block = format_tools_history_block(
_merge_call_and_result_entries([], results)
)
if block:
parts.append(f"{cot_prefix}{block}" if cot_prefix else block)
if user_text:
parts.append(f"[user]\n{user_text}")
i += 1
continue
parts.append(f"{cot_prefix}[user]\n{flatten_text(m.get('content'))}")
i += 1
return "\n\n".join(p for p in parts if p)