File size: 19,405 Bytes
fa1140b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
"""adapter 公共工具:messages 归一化、model 映射、system 软化、tool 历史拍扁。

模型目录来自 :mod:`app.upstream.models`(占位,用 scripts/probe_catalog.py 探测后填入),
本模块不再硬编码 catalog。

工具协议双通道:
- **模型输出**:``<tool_call>{...}</tool_call>``(宿主解析)
- **历史进上游**:``[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"<thinking>\n{t}\n</thinking>"
    if block.get("type") == "redacted_thinking":
        return "<redacted_thinking/>"
    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"<reasoning>\n{text}\n</reasoning>" 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:
    """模型**输出**协议:单个 ``<tool_call>{...}</tool_call>``(宿主解析用,可带 id)。"""
    args = _coerce_tool_arguments(arguments)
    payload: dict[str, Any] = {"name": name or "", "arguments": args}
    if call_id:
        payload["id"] = call_id
    return f"<tool_call>{json.dumps(payload, ensure_ascii=False)}</tool_call>"


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_call>...</tool_call>``。
    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"<reasoning>\n{reasoning}\n</reasoning>\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)