| """anuma.ai UpstreamClient:把拍平 prompt 组装成 Responses 请求,SSE 解析产 IREvent。 |
| |
| 上游:``POST https://portal.anuma.ai/api/v1/responses`` |
| - 认证头由 AuthProvider 注入(authorization: Bearer <privy JWT> + x-anuma-* 头)。 |
| - 请求体为标准 OpenAI Responses 格式(input/model/stream)。 |
| - **原生 function calling**:客户端 tools 直接透传上游 body(上游原生执行并返回 |
| function_call 事件);多轮 tool 历史([tools] 块)解析回原生的 |
| function_call / function_call_output input 项。 |
| - 响应为标准 OpenAI Responses SSE(response.created / output_text.delta / completed)。 |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
| import time |
| import uuid |
| from collections.abc import AsyncIterator |
| from pathlib import Path |
| from typing import Any |
|
|
| import httpx |
|
|
| from app.events import IREvent |
| from app.tools import ToolDef |
| from app.upstream.base import UpstreamClient |
|
|
| RESPONSES_URL = "https://portal.anuma.ai/api/v1/responses" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _SYSTEM_PROMPT = ( |
| Path(__file__).resolve().parent.parent.parent |
| / "scripts" / "har_system_full.txt" |
| ).read_text(encoding="utf-8") |
| |
| |
| print(f"[client] _SYSTEM_PROMPT loaded {len(_SYSTEM_PROMPT)} chars " |
| f"from {Path(__file__).resolve().parent.parent.parent / 'scripts' / 'har_system_full.txt'}", flush=True) |
|
|
| |
| |
| |
| |
| _IMAGE_TOOL = { |
| "type": "function", |
| |
| |
| |
| |
| "name": "AnumaMediaMCP-anuma_create_image", |
| "description": "Turn a prompt into one or more images. Optionally provide input images to edit or combine them. The optional configuration parameters are best-effort and depend on the internal provider details of each model. Returns signed URLs pointing to the generated images.", |
| "parameters": { |
| "type": "object", |
| "properties": { |
| "aspect_ratio": {"type": "string", "enum": ["1:1", "3:4", "4:3", "16:9", "9:16"], |
| "description": "The aspect ratio of the generated image."}, |
| |
| |
| |
| |
| "input_images": { |
| "type": "array", |
| "items": {"type": "string", "format": "uri", "maxLength": 28000000, |
| "description": "An http(s) URL or a base64-encoded data URI of the input image."}, |
| "minItems": 1, "maxItems": 4, |
| "description": "Input images to edit or combine. Provide between 1 and 4 images; omit to generate from the prompt alone.", |
| }, |
| "model": {"type": "string", |
| "enum": ["auto", "anuma-flash", "anuma-pro", "anuma-flash-private", |
| "anuma-pro-private", "flux-2-klein-4b", "flux-2-pro", |
| "gpt-image-2", "grok-imagine", "nano-banana", |
| "nano-banana-pro", "nano-banana-2"], |
| "description": "The model used to generate the image. Use a tier alias (\"auto\", \"anuma-flash\", \"anuma-pro\", \"anuma-flash-private\", \"anuma-pro-private\") or a concrete model name."}, |
| "num_images": {"type": "integer", "minimum": 1, "maximum": 4, |
| "description": "The number of images to generate. Defaults to 1. The flux-2-pro model always generates a single image and ignores this value."}, |
| "output_format": {"type": "string", "enum": ["jpeg", "png"], |
| "description": "The format of the generated image."}, |
| "prompt": {"type": "string", "minLength": 1, "maxLength": 2000, |
| "description": "The text prompt describing the image to generate, or the edits to make when input images are provided. Resolution keywords such as 2k, 4k, 8k, or uhd select a higher output resolution for the nano-banana-pro and nano-banana-2 models and increase the cost accordingly."}, |
| }, |
| "required": ["prompt", "model"], |
| }, |
| } |
|
|
| |
| _ROLE_RE = re.compile(r"^\[(system|user|assistant|tools)\]\n", re.MULTILINE) |
| |
| _TOOLS_ENTRY_RE = re.compile( |
| r"\[([^\]]+)\]\s*" |
| r"(?:(?:^|\n)name:\s*([^\n]*))?" |
| r"(?:(?:^|\n)arguments:\s*(\{.*?\}))?" |
| r"(?:\n---\s*\n(?:result(?:\s*\(error\))?:\s*\n?)?(.*?))?(?=\n\[|\Z)", |
| re.DOTALL, |
| ) |
|
|
|
|
| def _split_role_blocks(prompt: str) -> list[dict[str, Any]]: |
| """把拍平 prompt(带 [system]/[user]/[assistant]/[tools] 标记)切回 Responses input 数组。 |
| |
| 分段规则(文本区间不重叠、无遗漏): |
| - marker 之前的裸文本 → user 消息。 |
| - ``[system]`` / ``[user]`` / ``[assistant]`` → 对应 role 消息。 |
| - ``[tools]`` 块 → 解析为原生 function_call + function_call_output 项 |
| (多轮 tool 历史,供上游原生理解)。 |
| - marker 之后的剩余裸文本 → user 消息。 |
| """ |
| markers = [m for m in _ROLE_RE.finditer(prompt)] |
| items: list[dict[str, Any]] = [] |
|
|
| def add_text(text: str) -> None: |
| text = text.strip() |
| if text: |
| items.append({"role": "user", "content": [{"type": "text", "text": text}]}) |
|
|
| if not markers: |
| add_text(prompt) |
| return items |
| add_text(prompt[:markers[0].start()]) |
| for i, m in enumerate(markers): |
| end = markers[i + 1].start() if i + 1 < len(markers) else len(prompt) |
| body = prompt[m.end():end].strip() |
| if m.group(1) == "tools": |
| items.extend(_parse_tools_block(body)) |
| elif body: |
| items.append({"role": m.group(1), "content": [{"type": "text", "text": body}]}) |
| return items |
|
|
|
|
| def _inject_time_before_user(input_items: list[dict[str, Any]], ts: str) -> None: |
| """在第一条 user 消息前插入 `Current time (precise): <ts>` 文本块(对齐 HAR 生图请求)。 |
| |
| HAR(连续同时生图)里 user 消息是两条独立 text: |
| [{"type":"text","text":"Current time (precise): 2026-08-08T08:43:50Z"}, |
| {"type":"text","text":"一只小猫"}] |
| 无 user(纯 system+tool 场景)则追加在末尾。 |
| """ |
| time_item = {"role": "user", "content": [{"type": "text", "text": f"Current time (precise): {ts}"}]} |
| for i, it in enumerate(input_items): |
| if it.get("role") == "user": |
| input_items.insert(i, time_item) |
| return |
| input_items.append(time_item) |
|
|
|
|
| def _flush_text(pending: list[str]) -> list[dict[str, Any]]: |
| if not pending: |
| return [] |
| return [{"role": "user", "content": [{"type": "text", "text": "\n\n".join(pending)}]}] |
|
|
|
|
| def _parse_tools_block(body: str) -> list[dict[str, Any]]: |
| """[tools] 历史块 → Responses 原生 input 项(function_call + function_call_output 配对)。""" |
| items: list[dict[str, Any]] = [] |
| for m in _TOOLS_ENTRY_RE.finditer(body): |
| cid = (m.group(1) or "").strip() |
| name = (m.group(2) or "").strip() |
| args_str = (m.group(3) or "{}").strip() |
| result = (m.group(4) or "").strip() |
| if not cid: |
| continue |
| if name: |
| items.append({ |
| "type": "function_call", |
| "call_id": cid, |
| "name": name, |
| "arguments": args_str, |
| }) |
| if result or True: |
| items.append({"type": "function_call_output", "call_id": cid, "output": result}) |
| return items |
|
|
|
|
| class DefaultUpstreamClient(UpstreamClient): |
| def __init__(self, account, settings, http_client, auth, parser, |
| account_file=None) -> None: |
| self._account = account |
| self._settings = settings |
| self._http: httpx.AsyncClient = http_client |
| self._auth = auth |
| self._parser = parser |
| self._account_file = account_file |
|
|
| async def stream( |
| self, |
| prompt: str, |
| model_id: str | None = None, |
| tools: list[ToolDef] | None = None, |
| **kw: Any, |
| ) -> AsyncIterator[IREvent]: |
| """发送 prompt 到上游。``tools`` 为客户端工具定义(native 模式透传上游)。 |
| |
| ``kw`` 支持 ``image_model``(生图:body 加 image_model,tools 里自动注入 |
| AnumaMediaMCP-anuma_create_image,由**上游服务端**执行生图——客户端无需 |
| 回传 function_call_output,图片以签名 URL 嵌入最终文本)。""" |
| upstream_model = self._upstream_model(model_id) |
| input_items = _split_role_blocks(prompt) |
| |
| |
| |
| input_items.insert(0, {"role": "system", "content": [{"type": "text", "text": _SYSTEM_PROMPT}]}) |
| |
| |
| |
| |
| if kw.get("image_model"): |
| t = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) |
| _inject_time_before_user(input_items, t) |
| body: dict[str, Any] = { |
| "input": input_items, |
| "model": upstream_model, |
| "stream": True, |
| } |
| |
| image_model = kw.get("image_model") |
| |
| |
| |
| image_inputs: list[str] = list(kw.get("image_inputs") or []) |
| if image_model: |
| body["image_model"] = image_model |
| |
| |
| |
| body["tool_choice"] = "required" |
| |
| body.setdefault("max_output_tokens", 32000) |
| |
| |
| |
| |
| |
| |
| if image_inputs and all(s.startswith("http") for s in image_inputs): |
| hist_text = ("[Previous generated image to edit] Edit the image(s) " |
| "referenced in this assistant message. Use the image URL " |
| "as an input image to the media tool, do not generate a " |
| "brand-new image from scratch.\n\n" |
| + "\n\n".join(f"[ ]({u})" for u in image_inputs[:4])) |
| input_items.append({ |
| "role": "assistant", |
| "content": [{"type": "text", "text": hist_text}], |
| }) |
| if tools: |
| body["tools"] = [ |
| { |
| "type": "function", |
| "name": t.name, |
| "description": t.description, |
| "parameters": t.parameters or {"type": "object", "properties": {}}, |
| "strict": False, |
| } |
| for t in tools |
| ] |
| |
| |
| |
| if (image_model or kw.get("image_prompt")) and not any( |
| t.name == _IMAGE_TOOL["name"] for t in (tools or []) |
| ): |
| tool_def = _IMAGE_TOOL |
| body.setdefault("tools", []).append(tool_def) |
| |
| |
| |
| if image_model: |
| conv_id = str(getattr(self._account, "conversation_id", "") or "") |
| if not conv_id: |
| conv_id = str(uuid.uuid4()) |
| self._account.conversation_id = conv_id |
| self._persist_account() |
| body["conversation_id"] = conv_id |
| else: |
| |
| |
| conv_id = str(getattr(self._account, "conversation_id", "") or "") |
| if not conv_id: |
| conv_id = str(uuid.uuid4()) |
| self._account.conversation_id = conv_id |
| body["conversation_id"] = conv_id |
| |
| |
| |
| |
| |
| body.setdefault("max_output_tokens", 32000) |
| |
| |
| |
| if not image_model and "reasoning" not in body: |
| body["reasoning"] = {"effort": "low", "summary": "concise"} |
|
|
| headers = await self._auth.get_auth() |
| headers["content-type"] = "application/json" |
|
|
| |
| |
| |
| |
| |
| |
| |
| stream_timeout = httpx.Timeout(300.0) if image_model else None |
| async with self._http.stream( |
| "POST", RESPONSES_URL, json=body, headers=headers, |
| timeout=stream_timeout, |
| ) as resp: |
| if resp.status_code >= 400: |
| body_text = (await resp.aread()).decode("utf-8", "replace") |
| raise httpx.HTTPStatusError( |
| f"anuma responses failed: {resp.status_code} {body_text}", |
| request=resp.request, |
| response=resp, |
| ) |
| async for line in resp.aiter_lines(): |
| if not line.startswith("data: "): |
| continue |
| payload = line[6:].strip() |
| if not payload or payload == "[DONE]": |
| continue |
| try: |
| raw = json.loads(payload) |
| except json.JSONDecodeError: |
| continue |
| for ir in self._parser.parse(raw): |
| yield ir |
|
|
| def _persist_account(self) -> None: |
| """把生成的 conversation_id 写回账号文件(失败不阻断请求)。""" |
| if self._account_file is None: |
| return |
| try: |
| self._account_file.parent.mkdir(parents=True, exist_ok=True) |
| tmp = self._account_file.with_suffix(".json.tmp") |
| tmp.write_text( |
| json.dumps(self._account.model_dump(mode="json"), |
| ensure_ascii=False, indent=2), |
| encoding="utf-8", |
| ) |
| tmp.replace(self._account_file) |
| except Exception: |
| pass |
|
|
| def _upstream_model(self, model_id: str | None) -> str: |
| if not model_id: |
| return "" |
| |
| |
| |
| |
| |
| low = model_id.lower() |
| |
| if "/" in low and not low.startswith("accounts/"): |
| return model_id |
| |
| short = low.rsplit("/", 1)[-1] |
| from app.upstream.models import MODEL_CATALOG |
| for m in MODEL_CATALOG: |
| if m["id"].rsplit("/", 1)[-1] == short: |
| return m["id"] |
| if (m.get("upstream_id") or "").lower() == low: |
| return m["id"] |
| return model_id |
|
|