diff --git a/.gitignore b/.gitignore index 9279c51..b8fdd2a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ serve.pid .sg_* .sg-js/ .sg_login.html +.zcode/ diff --git a/app/response_store.py b/app/response_store.py new file mode 100644 index 0000000..592807a --- /dev/null +++ b/app/response_store.py @@ -0,0 +1,58 @@ +"""previous_response_id 状态存储 —— /v1/responses 专用内存 LRU。 + +OpenAI Responses 协议的多轮续接靠 previous_response_id 引用上一轮的 +input/output 条目;上游 Anthropic 协议是无状态的,网关在两次请求之间把 +历史条目暂存在这里,下一轮命中时合并成完整 messages 重放。 + +设计要点: +- 仅内存(OrderedDict 上限 256 条,超出淘汰最旧),进程重启即清零: + 定位是续接便利而非持久会话,客户端随时可重发全量 input 兜底 +- 与 reqlog 同一哲学:避免写库的 IO 放大,丢失可接受、绝不放大请求失败 +- 存取均为浅拷贝列表(防调用方原地改写污染库内条目),条目 dict 本体不深拷贝 +""" + +from __future__ import annotations + +import threading +from collections import OrderedDict + +_MAX = 256 + +_lock = threading.Lock() +_store: OrderedDict[str, dict] = OrderedDict() + + +def save(response_id: str, input_items: list, output_items: list) -> None: + """保存一轮响应的 input/output 条目(各存浅拷贝列表)。""" + with _lock: + if response_id in _store: + _store.move_to_end(response_id) + _store[response_id] = { + "input_items": list(input_items), + "output_items": list(output_items), + } + while len(_store) > _MAX: + _store.popitem(last=False) # 淘汰最旧 + + +def get(response_id: str) -> dict | None: + """取回条目(命中移到队尾刷新 LRU 位次);未命中返回 None。""" + with _lock: + item = _store.get(response_id) + if item is None: + return None + _store.move_to_end(response_id) + return { + "input_items": list(item["input_items"]), + "output_items": list(item["output_items"]), + } + + +def clear() -> None: + with _lock: + _store.clear() + + +def size() -> int: + with _lock: + return len(_store) diff --git a/app/responses_compat.py b/app/responses_compat.py new file mode 100644 index 0000000..23c8576 --- /dev/null +++ b/app/responses_compat.py @@ -0,0 +1,555 @@ +"""OpenAI Responses 兼容层 —— /v1/responses ↔ Anthropic Messages 双向转换。 + +入站:Responses 请求体 → Anthropic messages 体(instructions→system、input +条目(message/reasoning/function_call/function_call_output)→ blocks、 +reasoning.effort→reasoning_effort、tools 扁平映射)。 +出站:Anthropic 响应(JSON 或 SSE 事件流)→ Responses 格式 +(output 条目 + response.created/.completed 事件帧)。 + +与 openai_compat 同一原则:对畸形输入保持宽容,映射不了的条目安静跳过, +绝不放大请求失败。previous_response_id 的历史合并由网关层(response_store) +完成后并入 input,本模块只做纯格式翻译。 +""" + +from __future__ import annotations + +import json +import time +import uuid + +# Responses reasoning.effort → Anthropic reasoning_effort +# 上游 GLM 原生接受 reasoning_effort(low/high/max),不支持关闭思考, +# medium 即上游默认档——不映射、不带该字段。 +_EFFORT_MAP = { + "minimal": "low", + "low": "low", + "high": "high", + "xhigh": "max", +} + + +def _as_int(value: object) -> int | None: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _image_block(url: str) -> dict | None: + """data:image/...;base64,xxx → Anthropic image block;外链 URL 无法回填,跳过。""" + if not url.startswith("data:"): + return None + head, _, b64 = url.partition(",") + media_type = head[5:].split(";")[0] or "image/png" + if not b64: + return None + return {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": b64}} + + +def _message_to_blocks(item: dict) -> dict | None: + """message 条目 → Anthropic 消息;无有效内容返回 None(整条丢弃)。 + + 兼容两种入参形态: + - Responses 规范:type="message",分块用 input_text / output_text + - chat 简写(部分客户端的 input 数组就长这样):无 type,分块用 text—— + 缺 type 的条目若带 role 同样按消息处理,否则会被当未知条目丢弃, + 最终发出空 messages 招致上游 400(实测 code 1214)。 + assistant 取 output_text/text,user(及其他角色按 user)取 + input_text/text 与 input_image(仅 data URL)。 + """ + role = item.get("role") + content = item.get("content") + if role == "assistant": + blocks: list[dict] = [] + if isinstance(content, str) and content: + blocks.append({"type": "text", "text": content}) + elif isinstance(content, list): + for part in content: + if (isinstance(part, dict) and part.get("type") in ("output_text", "text") + and isinstance(part.get("text"), str) and part["text"]): + blocks.append({"type": "text", "text": part["text"]}) + return {"role": "assistant", "content": blocks} if blocks else None + # user 及未知角色一律按 user 处理 + blocks = [] + if isinstance(content, str) and content: + blocks.append({"type": "text", "text": content}) + elif isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype in ("input_text", "text") and isinstance(part.get("text"), str) and part["text"]: + blocks.append({"type": "text", "text": part["text"]}) + elif ptype == "input_image": + url = part.get("image_url") + if isinstance(url, dict): + url = url.get("url") or "" + block = _image_block(str(url)) + if block: + blocks.append(block) + return {"role": "user", "content": blocks} if blocks else None + + +def _reasoning_to_thinking(item: dict) -> dict | None: + """type=="reasoning" 条目 → assistant 消息(thinking 块)。 + + 优先取 content[](type reasoning_text)的原文,缺失退回 summary[](type + summary_text);两者皆空则整条跳过。thinking 块 signature 上游不校验 + (实测伪造值可通过),空串亦可——同 openai_compat 重放 reasoning 的先例。 + """ + texts: list[str] = [] + for part in item.get("content") or []: + if (isinstance(part, dict) and part.get("type") == "reasoning_text" + and isinstance(part.get("text"), str) and part["text"]): + texts.append(part["text"]) + if not texts: + for part in item.get("summary") or []: + if (isinstance(part, dict) and part.get("type") == "summary_text" + and isinstance(part.get("text"), str) and part["text"]): + texts.append(part["text"]) + text = "\n".join(texts) + if not text: + return None + return {"role": "assistant", "content": [{"type": "thinking", "thinking": text, "signature": ""}]} + + +def _function_call_to_msg(item: dict) -> dict: + """type=="function_call" 条目 → assistant 消息(tool_use 块)。 + + arguments 为 JSON 字符串,解析失败不丢数据,包成 {"_raw": ...} 上送 + (同 openai_compat 的 tool_calls 宽容策略)。 + """ + args = item.get("arguments") + if isinstance(args, str): + try: + args = json.loads(args or "{}") + except ValueError: + args = {"_raw": args} + elif not isinstance(args, dict): + args = {} + return {"role": "assistant", "content": [{ + "type": "tool_use", + "id": str(item.get("call_id") or ""), + "name": str(item.get("name") or ""), + "input": args if isinstance(args, dict) else {}, + }]} + + +def _function_output_to_msg(item: dict) -> dict: + """type=="function_call_output" 条目 → user 消息(tool_result 块)。 + + output 为 str 直接用;为 list 时拼接其中 output_text 的 text + ( Responses 规范的 content parts 形态)。 + """ + output = item.get("output") + if isinstance(output, list): + parts = [] + for part in output: + if (isinstance(part, dict) and part.get("type") == "output_text" + and isinstance(part.get("text"), str)): + parts.append(part["text"]) + content = "\n".join(p for p in parts if p) + elif isinstance(output, str): + content = output + else: + content = "" + return {"role": "user", "content": [{ + "type": "tool_result", + "tool_use_id": str(item.get("call_id") or ""), + "content": content, + }]} + + +def responses_to_anthropic(payload: dict) -> tuple[dict | None, str | None]: + """Responses 请求体 → Anthropic messages 体。非法时返回 (None, 错误信息)。""" + model = payload.get("model") + if not isinstance(model, str) or not model.strip(): + return None, "必须提供 model 参数" + + raw_input = payload.get("input") + if isinstance(raw_input, str): + # 字符串 input → 单条 user 消息 + items: list = [] + if raw_input: + items = [{"type": "message", "role": "user", + "content": [{"type": "input_text", "text": raw_input}]}] + elif isinstance(raw_input, list): + items = raw_input + else: + items = [] + # input 为空且无 previous_response_id 链入内容时才报错 + #(网关层命中历史后已合并进 input;直接调用时以 previous_response_id 字段为准) + if not items and not payload.get("previous_response_id"): + return None, "必须提供 input" + + out_msgs: list[dict] = [] + for item in items: + if not isinstance(item, dict): + continue # 非 dict 条目安静跳过 + itype = item.get("type") + # type 缺失但带 role 的条目按消息处理(客户端 chat 简写发法) + if itype == "message" or (itype is None and item.get("role")): + msg = _message_to_blocks(item) + if msg is not None: + out_msgs.append(msg) + elif itype == "reasoning": + msg = _reasoning_to_thinking(item) + if msg is not None: + out_msgs.append(msg) + elif itype == "function_call": + out_msgs.append(_function_call_to_msg(item)) + elif itype == "function_call_output": + out_msgs.append(_function_output_to_msg(item)) + # item_reference / 未知类型:安静跳过 + + # 相邻同 role 消息合并(blocks 拼接;Anthropic 要求 role 严格交替) + messages: list[dict] = [] + for msg in out_msgs: + if messages and messages[-1]["role"] == msg["role"]: + messages[-1]["content"] = messages[-1]["content"] + msg["content"] + else: + messages.append(msg) + # 全部条目都映射不出内容 → 本地 400 早失败:空 messages 发上游只会换来 + # 语焉不详的 1214「messages 参数非法」,客户端无法据此定位入参问题 + if not messages: + return None, ("input 未包含可用消息条目(支持 message/reasoning/" + "function_call/function_call_output,message 条目可省略 type 只留 role+content)") + + body: dict = { + "model": model, + "messages": messages, + "max_tokens": _as_int(payload.get("max_output_tokens")) or 4096, + } + instructions = payload.get("instructions") + if isinstance(instructions, str) and instructions.strip(): + body["system"] = instructions + elif isinstance(instructions, list): + parts = [p for p in instructions if isinstance(p, str) and p.strip()] + if parts: + body["system"] = "\n\n".join(parts) + try: + if payload.get("temperature") is not None: + body["temperature"] = float(payload["temperature"]) + if payload.get("top_p") is not None: + body["top_p"] = float(payload["top_p"]) + except (TypeError, ValueError): + pass + # 推理强度:上游 GLM 原生接受 reasoning_effort(low/high/max),不支持关思考 + # —— minimal/low 降到 low(低速档),xhigh 升到 max,medium 即默认档不带字段 + effort = (payload.get("reasoning") or {}).get("effort") if isinstance(payload.get("reasoning"), dict) else None + if isinstance(effort, str) and effort.strip().lower() in _EFFORT_MAP: + body["reasoning_effort"] = _EFFORT_MAP[effort.strip().lower()] + if payload.get("stream"): + body["stream"] = True + + tools = payload.get("tools") + if isinstance(tools, list) and tools: + mapped = [] + for t in tools: + # 仅 function 工具可映射;web_search 等内置类型上游无对应物,跳过 + if isinstance(t, dict) and t.get("type") == "function" and t.get("name"): + mapped.append({ + "name": str(t["name"]), + "description": str(t.get("description") or ""), + "input_schema": t.get("parameters") if isinstance(t.get("parameters"), dict) else {"type": "object"}, + }) + if mapped: + body["tools"] = mapped + + choice = payload.get("tool_choice") + if choice == "none": + body.pop("tools", None) + elif choice == "required": + body["tool_choice"] = {"type": "any"} + elif isinstance(choice, dict) and choice.get("type") == "function": + name = str(choice.get("name") or "") + if name: + body["tool_choice"] = {"type": "tool", "name": name} + # "auto"/缺省:Anthropic 默认即 auto,无需显式映射 + return body, None + + +def anthropic_to_responses(data: dict, model: str) -> dict: + """Anthropic message 响应 → OpenAI response 对象。""" + output: list[dict] = [] + for block in data.get("content") or []: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "thinking" and isinstance(block.get("thinking"), str): + output.append({ + "type": "reasoning", + "id": f"rs_{uuid.uuid4().hex[:12]}", + "summary": [{"type": "summary_text", "text": block["thinking"]}], + }) + elif btype == "text" and isinstance(block.get("text"), str): + output.append({ + "type": "message", + "id": f"msg_{uuid.uuid4().hex[:12]}", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": block["text"], "annotations": []}], + }) + elif btype == "tool_use": + output.append({ + "type": "function_call", + "id": f"fc_{uuid.uuid4().hex[:12]}", + "call_id": str(block.get("id") or ""), + "name": str(block.get("name") or ""), + "arguments": json.dumps(block.get("input") or {}, ensure_ascii=False), + }) + usage = data.get("usage") or {} + in_tok = _as_int(usage.get("input_tokens")) or 0 + out_tok = _as_int(usage.get("output_tokens")) or 0 + if data.get("stop_reason") == "max_tokens": + status = "incomplete" + incomplete_details: dict | None = {"reason": "max_output_tokens"} + else: + status = "completed" + incomplete_details = None + return { + "id": f"resp_{uuid.uuid4().hex[:24]}", + "object": "response", + "created_at": int(time.time()), + "status": status, + "model": str(data.get("model") or model), + "output": output, + "error": None, + "incomplete_details": incomplete_details, + "usage": { + "input_tokens": in_tok, + "output_tokens": out_tok, + "total_tokens": in_tok + out_tok, + "input_tokens_details": { + "cached_tokens": _as_int(usage.get("cache_read_input_tokens")) or 0, + }, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + } + + +class ResponsesStreamConverter: + """Anthropic SSE 事件流 → OpenAI Responses 事件流(有状态转换器)。 + + 用法:先 start() 产出 response.created + response.in_progress 两帧, + 逐条 feed(event_dict) 收输出帧,流结束后调用 done()。每帧形如 + `event: \\ndata: {json}\\n\\n`,data 内 sequence_number 严格递增。 + """ + + def __init__(self, model: str) -> None: + self.model = model + self.response_id = f"resp_{uuid.uuid4().hex[:24]}" + self.created_at = int(time.time()) + self.sequence_number = 0 + # None = 上游未上报(未知),0 = 真实为零;两者语义不同 + self.usage = {"input_tokens": None, "output_tokens": None, "total_tokens": None} + self.output_items: list[dict] = [] # 已完成的 output 条目(response.completed 引用) + self._stop_reason: str | None = None + self._output_index = -1 # 当前 content block 的 output_index + self._cur_type: str | None = None + self._cur_item_id: str | None = None + self._cur_text = "" # thinking / text 累积 + self._cur_args = "" # tool_use arguments 累积 + self._cur_call_id = "" + self._cur_name = "" + + def _sync_usage_total(self) -> None: + i, o = self.usage["input_tokens"], self.usage["output_tokens"] + self.usage["total_tokens"] = (i or 0) + (o or 0) if i is not None or o is not None else None + + def _reset_cur(self) -> None: + self._cur_type = None + self._cur_item_id = None + self._cur_text = "" + self._cur_args = "" + self._cur_call_id = "" + self._cur_name = "" + + # ── 帧构造 ──────────────────────────────────────────────────────────── + def _event(self, etype: str, payload: dict) -> str: + frame = {"type": etype, "sequence_number": self.sequence_number, **payload} + self.sequence_number += 1 + return f"event: {etype}\ndata: {json.dumps(frame, ensure_ascii=False)}\n\n" + + def _snapshot(self, status: str, output: list) -> dict: + return { + "id": self.response_id, + "object": "response", + "type": "response", + "created_at": self.created_at, + "status": status, + "model": self.model, + "output": output, + "error": None, + "incomplete_details": None, + "usage": dict(self.usage), + } + + def start(self) -> str: + snap = self._snapshot("in_progress", []) + return self._event("response.created", {"response": snap}) \ + + self._event("response.in_progress", {"response": snap}) + + def done(self) -> str: + # Responses 流没有 [DONE] 哨兵(OpenAI 规范以 response.completed 收尾), + # 恒返回空串,网关据此不再 yield 任何收尾帧 + return "" + + # ── 上游事件处理 ────────────────────────────────────────────────────── + def feed(self, evt: dict) -> list[str]: + etype = evt.get("type") + if etype == "message_start": + msg = evt.get("message") or {} + if msg.get("id"): + self.response_id = f"resp_{msg['id']}" + in_tok = _as_int((msg.get("usage") or {}).get("input_tokens")) + if in_tok is not None: + self.usage["input_tokens"] = in_tok + self._sync_usage_total() + return [] + if etype == "content_block_start": + return self._on_block_start(evt.get("content_block") or {}) + if etype == "content_block_delta": + return self._on_block_delta(evt.get("delta") or {}) + if etype == "content_block_stop": + return self._on_block_stop() + if etype == "message_delta": + delta = evt.get("delta") or {} + if delta.get("stop_reason"): + self._stop_reason = str(delta["stop_reason"]) + out = _as_int((evt.get("usage") or {}).get("output_tokens")) + if out is not None: + self.usage["output_tokens"] = out + self._sync_usage_total() + return [] + if etype == "message_stop": + return [self._completed_frame()] + return [] # ping / error 等无需产出 + + def _on_block_start(self, block: dict) -> list[str]: + btype = block.get("type") + if btype not in ("thinking", "text", "tool_use"): + return [] # 未识别的块不占 output_index,安静跳过 + self._output_index += 1 + self._cur_type = btype + idx = self._output_index + if btype == "thinking": + self._cur_item_id = f"rs_{uuid.uuid4().hex[:12]}" + return [ + self._event("response.output_item.added", { + "output_index": idx, + "item": {"type": "reasoning", "id": self._cur_item_id, "summary": []}, + }), + self._event("response.reasoning_summary_part.added", { + "item_id": self._cur_item_id, "output_index": idx, "summary_index": 0, + "part": {"type": "summary_text", "text": ""}, + }), + ] + if btype == "text": + self._cur_item_id = f"msg_{uuid.uuid4().hex[:12]}" + return [ + self._event("response.output_item.added", { + "output_index": idx, + "item": {"type": "message", "id": self._cur_item_id, "role": "assistant", + "status": "in_progress", "content": []}, + }), + self._event("response.content_part.added", { + "item_id": self._cur_item_id, "output_index": idx, "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + }), + ] + # tool_use + self._cur_item_id = f"fc_{uuid.uuid4().hex[:12]}" + self._cur_call_id = str(block.get("id") or "") + self._cur_name = str(block.get("name") or "") + return [self._event("response.output_item.added", { + "output_index": idx, + "item": {"type": "function_call", "id": self._cur_item_id, + "call_id": self._cur_call_id, "name": self._cur_name, + "arguments": "", "status": "in_progress"}, + })] + + def _on_block_delta(self, delta: dict) -> list[str]: + dtype = delta.get("type") + idx = self._output_index + item_id = self._cur_item_id + if dtype == "thinking_delta" and isinstance(delta.get("thinking"), str): + self._cur_text += delta["thinking"] + return [self._event("response.reasoning_summary_text.delta", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "delta": delta["thinking"], + })] + if dtype == "text_delta" and isinstance(delta.get("text"), str): + self._cur_text += delta["text"] + return [self._event("response.output_text.delta", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "delta": delta["text"], + })] + if dtype == "input_json_delta" and isinstance(delta.get("partial_json"), str): + self._cur_args += delta["partial_json"] + return [self._event("response.function_call_arguments.delta", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "delta": delta["partial_json"], + })] + return [] + + def _on_block_stop(self) -> list[str]: + idx = self._output_index + item_id = self._cur_item_id + if self._cur_type == "thinking": + item = {"type": "reasoning", "id": item_id, + "summary": [{"type": "summary_text", "text": self._cur_text}]} + frames = [ + self._event("response.reasoning_summary_text.done", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "text": self._cur_text, + }), + self._event("response.reasoning_summary_part.done", { + "item_id": item_id, "output_index": idx, "summary_index": 0, + "part": {"type": "summary_text", "text": self._cur_text}, + }), + ] + elif self._cur_type == "text": + item = {"type": "message", "id": item_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": self._cur_text, "annotations": []}]} + frames = [ + self._event("response.output_text.done", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "text": self._cur_text, + }), + self._event("response.content_part.done", { + "item_id": item_id, "output_index": idx, "content_index": 0, + "part": {"type": "output_text", "text": self._cur_text, "annotations": []}, + }), + ] + elif self._cur_type == "tool_use": + item = {"type": "function_call", "id": item_id, "call_id": self._cur_call_id, + "name": self._cur_name, "arguments": self._cur_args, "status": "completed"} + frames = [ + self._event("response.function_call_arguments.done", { + "item_id": item_id, "output_index": idx, + "arguments": self._cur_args, + }), + ] + else: + return [] + frames.append(self._event("response.output_item.done", { + "output_index": idx, "item": item, + })) + self.output_items.append(item) + self._reset_cur() + return frames + + def _completed_frame(self) -> str: + if self._stop_reason == "max_tokens": + status = "incomplete" + details: dict | None = {"reason": "max_output_tokens"} + else: + status = "completed" + details = None + snap = self._snapshot(status, list(self.output_items)) + snap["incomplete_details"] = details + return self._event("response.completed", {"response": snap}) diff --git a/app/routes/gateway.py b/app/routes/gateway.py index 714f951..7a803cb 100644 --- a/app/routes/gateway.py +++ b/app/routes/gateway.py @@ -15,13 +15,18 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse, StreamingResponse -from .. import constants, logs, reqlog, settings +from .. import constants, logs, reqlog, response_store, settings from ..agent import build_request from ..auth_admin import verify_gateway_key from ..captcha import captcha_manager from ..models import Account, Status from ..openai_compat import StreamConverter, anthropic_to_openai, openai_to_anthropic from ..quota import fetch_quota +from ..responses_compat import ( + ResponsesStreamConverter, + anthropic_to_responses, + responses_to_anthropic, +) from ..store import store _sleep = asyncio.sleep # 模块级引用:测试可 patch 此名而免污染全局 asyncio @@ -330,6 +335,146 @@ async def _iter(): headers={"Cache-Control": "no-cache"}) +@router.post("/v1/responses", dependencies=[Depends(verify_gateway_key)]) +async def responses_api(request: Request): + try: + payload = await request.json() + except (json.JSONDecodeError, ValueError): + return JSONResponse({"error": {"message": "请求体不是合法 JSON", "type": "invalid_request_error"}}, status_code=400) + if not isinstance(payload, dict): + return JSONResponse({"error": {"message": "请求体必须是 JSON 对象", "type": "invalid_request_error"}}, status_code=400) + + # previous_response_id 预处理:从内存库链入上一轮 input/output 条目, + # 合并成完整条目数组后按无状态请求转发(未命中即 400,客户端应重发全量 input)。 + # 当前请求的 input 无论是否链历史都归一为条目数组——首轮也要入库, + # 否则下一轮链入时缺第一轮 user 内容(OpenAI Responses 续接语义即全量重放)。 + pid = payload.get("previous_response_id") + raw_input = payload.get("input") + if isinstance(raw_input, str): + new_items = [{"type": "message", "role": "user", + "content": [{"type": "input_text", "text": raw_input}]}] if raw_input else [] + elif isinstance(raw_input, list): + new_items = raw_input + else: + new_items = [] + store_input_items: list = new_items + if pid: + st = response_store.get(str(pid)) + if st is None: + return JSONResponse( + {"error": {"message": "previous_response_id 未找到或已过期", "type": "invalid_request_error"}}, + status_code=400, + ) + merged = st["input_items"] + st["output_items"] + new_items + payload = {**payload, "input": merged} + payload.pop("previous_response_id", None) + store_input_items = merged # 保留引用:成功后原样入库供下一轮续接 + + body, err = responses_to_anthropic(payload) + if err or body is None: + return JSONResponse({"error": {"message": err or "请求体不合法", "type": "invalid_request_error"}}, status_code=400) + + incoming_headers = dict(request.headers) + provider = _detect_provider(body, request.headers) + body = _normalize_body(body) + port = request.url.port or settings.PORT + + req_id = secrets.token_hex(8) + logs.req(req_id, str(body.get("model") or "-"), bool(payload.get("stream")), _last_user_text(body)) + reqlog.begin(req_id, "responses", str(body.get("model") or "-"), + bool(payload.get("stream")), _last_user_text(body)) + + try: + result = await _dispatch(req_id, body, incoming_headers, port, provider) + except asyncio.CancelledError: + reqlog.finish_error(req_id, "客户端断开", status=499) + raise + except Exception as err: # noqa: BLE001 - 调度层意外异常也要收口监控条目 + reqlog.finish_error(req_id, f"网关内部错误: {err}", status=500) + return JSONResponse( + {"error": {"message": "网关内部错误", "type": "internal_error"}}, + status_code=500, + ) + if not isinstance(result, _Upstream): + return result + + model = str(body.get("model") or "") + if payload.get("stream"): + try: + return _responses_stream_response(result, model, req_id, store_input_items) + except asyncio.CancelledError: + await result.close() + raise + + try: + raw = await result.resp.aread() + logs.req_ok(req_id) + except asyncio.CancelledError: + reqlog.finish_error(req_id, "客户端断开", status=499, t_first=result.t_first) + raise + except Exception as err: # noqa: BLE001 + logs.req_err(req_id, f"读取上游响应失败: {err}") + reqlog.finish_error(req_id, f"读取上游响应失败: {err}", status=502) + return JSONResponse({"error": {"message": f"读取上游响应失败: {err}", "type": "upstream_error"}}, status_code=502) + finally: + await result.close() + data = _safe_json(raw.decode("utf-8", "ignore")) + if not isinstance(data, dict) or data.get("type") != "message": + reqlog.finish_error(req_id, "上游响应格式异常", status=502, t_first=result.t_first) + return JSONResponse({"error": {"message": "上游响应格式异常", "type": "upstream_error"}}, status_code=502) + usage = data.get("usage") or {} + reqlog.finish_ok(req_id, t_first=result.t_first, status=result.resp.status_code, + input_tokens=usage.get("input_tokens"), output_tokens=usage.get("output_tokens")) + resp = anthropic_to_responses(data, model) + response_store.save(resp["id"], store_input_items, resp["output"]) + return JSONResponse(resp) + + +def _responses_stream_response(up: _Upstream, model: str, req_id: str, + store_input_items: list) -> StreamingResponse: + """把上游 Anthropic SSE 事件流转换为 OpenAI Responses 事件流。 + + 成功收尾时把历史条目 + 本次 output 入库(response_store),供下一轮 + previous_response_id 续接;中断路径不入库(半截输出不可安全重放)。 + """ + conv = ResponsesStreamConverter(model) + + async def _iter(): + try: + first = conv.start() + if first: + yield first + async for line in up.resp.aiter_lines(): + if not line.startswith("data:"): + continue + data_str = line[5:].strip() + if not data_str: + continue + evt = _safe_json(data_str) + if isinstance(evt, dict): + for out in conv.feed(evt): + yield out + tail = conv.done() # Responses 流无 [DONE] 哨兵,done() 恒为空串 + if tail: + yield tail + response_store.save(conv.response_id, store_input_items, conv.output_items) + logs.req_ok(req_id) + reqlog.finish_ok(req_id, t_first=up.t_first, status=up.resp.status_code, + input_tokens=conv.usage.get("input_tokens"), + output_tokens=conv.usage.get("output_tokens")) + except asyncio.CancelledError: + reqlog.finish_error(req_id, "客户端断开", status=499, t_first=up.t_first) + raise + except Exception as err: # noqa: BLE001 + logs.req_err(req_id, f"流传输中断: {err}") + reqlog.finish_error(req_id, f"流传输中断: {err}", t_first=up.t_first) + finally: + await up.close() + + return StreamingResponse(_iter(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache"}) + + async def _dispatch(req_id, body, incoming_headers, port, provider): """多账号轮询调度:_Upstream(成功)或 JSONResponse(错误)。 diff --git a/frontend/admin/settings.html b/frontend/admin/settings.html index 8c26ad5..584f91d 100644 --- a/frontend/admin/settings.html +++ b/frontend/admin/settings.html @@ -28,7 +28,7 @@
-
设置后,调用 /v1/messages 须携带 Authorization: Bearer <key> 或 x-api-key。清空并保存可关闭校验(生产必须配置)。输入框显示掩码,未改动保存不会覆盖原值。
+
设置后,调用 /v1/messages 或 /v1/chat/completions 须携带 Authorization: Bearer <key> 或 x-api-key。清空并保存可关闭校验(生产必须配置)。输入框显示掩码,未改动保存不会覆盖原值。
@@ -57,7 +57,9 @@ · 在「账号池」粘贴 Coding Plan JWT 或 API Key 即可加入轮询。
· 请求按 round-robin 分发;某账号额度用完会自动切到下一个账号。
· 账号额度、状态在「账号池」页实时刷新展示。
- · 对话端点:/v1/messages(兼容 Anthropic Messages 协议)。 + · 对话端点(Anthropic 协议):/v1/messages
+ · 对话端点(OpenAI 协议):/v1/chat/completions
+ · 模型列表:/v1/models
@@ -70,7 +72,10 @@ await renderAdminHeader(); const key=await adminKey.get(); if(!key||!await verifyKey(ADMIN_API+'/verify',key).catch(()=>false))return location.href='/admin/login'; - document.getElementById('endpoint').textContent=location.origin+'/v1/messages'; + const o=location.origin; + document.getElementById('endpoint-anthropic').textContent=o+'/v1/messages'; + document.getElementById('endpoint-openai').textContent=o+'/v1/chat/completions'; + document.getElementById('endpoint-models').textContent=o+'/v1/models'; load(); })(); diff --git a/frontend/css/app.css b/frontend/css/app.css index 6e6d14a..d1ef7a9 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -243,7 +243,8 @@ select.input option{background:var(--panel);color:var(--ink)} .dialog-btn-danger:hover{background:rgba(224,82,82,.2)} /* Settings card */ -.settings-card{background:var(--panel);border:1px solid var(--hairline-soft);border-radius:14px;padding:22px;margin-bottom:16px;max-width:640px} +/* Settings card:与 admin-main 同宽(设置页表单/说明需要整行宽度,窄卡已弃用) */ +.settings-card{background:var(--panel);border:1px solid var(--hairline-soft);border-radius:14px;padding:22px;margin:0 auto 16px} .settings-row{display:grid;gap:6px;margin-bottom:18px} .settings-row:last-child{margin-bottom:0} .settings-row label{font-size:13px;font-weight:600} diff --git a/frontend/version b/frontend/version index 4484c2b..a04abec 100644 --- a/frontend/version +++ b/frontend/version @@ -1 +1 @@ -2.6.8 \ No newline at end of file +2.6.10 diff --git a/tests/integration/test_gateway_responses.py b/tests/integration/test_gateway_responses.py new file mode 100644 index 0000000..6ec64c2 --- /dev/null +++ b/tests/integration/test_gateway_responses.py @@ -0,0 +1,137 @@ +"""GW-012 /v1/responses(OpenAI Responses 风格)端点集成测试。 + +复用 /v1/messages 同一套 mock 上游(返回 Anthropic 格式),验证双向转换: +非流式 response 对象、流式 response.* 事件帧,以及 previous_response_id +跨轮续接(response_store 链入历史)。 +""" + +from __future__ import annotations + +import json + +import pytest + +# JWT 前缀是 mock 上游按 bind 键控 counters 的依据(session 级共享), +# 不能与既有测试文件的凭证前缀撞车(connect_fail 的 n==0 判定会被破坏)。 +_GOOD_JWT = "hZ.eyJzdWIiOiJ6In0.sig" + + +@pytest.mark.integration +class TestResponsesEndpoint: + async def test_nonstream_basic(self, gateway_client, fresh_app): + client, mock = gateway_client + from tests.conftest import seed_account + + seed_account(fresh_app, _GOOD_JWT, name="resp") + res = await client.post( + "/v1/responses", + json={"model": "GLM-5.3", "input": "你好"}, + ) + assert res.status_code == 200 + data = res.json() + assert data["object"] == "response" + assert data["id"].startswith("resp_") + assert data["status"] == "completed" + assert data["model"] == "GLM-5.3" + msg_items = [o for o in data["output"] if o["type"] == "message"] + assert msg_items, "output 应含 message 条目" + texts = [p["text"] for p in msg_items[0]["content"] if p["type"] == "output_text"] + assert "Hello from mock upstream" in "".join(texts) + assert data["usage"]["input_tokens"] == 10 + assert data["usage"]["output_tokens"] == 5 + + # 上游收到的 body:messages[0] 是 user 文本、model 正确 + payload = json.loads(mock.state.calls[-1][3]) + assert payload["model"] == "GLM-5.3" + assert payload["messages"][0]["role"] == "user" + assert payload["messages"][0]["content"][0]["type"] == "text" + assert payload["messages"][0]["content"][0]["text"] == "你好" + + async def test_stream_emits_response_events(self, gateway_client, fresh_app): + client, mock = gateway_client + from tests.conftest import seed_account + + seed_account(fresh_app, _GOOD_JWT, name="resp-stream") + res = await client.post( + "/v1/responses", + json={"model": "glm-5.3", "stream": True, "input": "hi"}, + ) + assert res.status_code == 200 + assert res.headers["content-type"].startswith("text/event-stream") + assert "event: response.created" in res.text + assert "event: response.completed" in res.text + assert "[DONE]" not in res.text # Responses 流没有 [DONE] 哨兵 + # 上游收到流式标记 + payload = json.loads(mock.state.calls[-1][3]) + assert payload["stream"] is True + + async def test_previous_response_id_chain(self, gateway_client, fresh_app): + """两轮对话:第二轮带 previous_response_id,上游应收到完整历史重放。""" + client, mock = gateway_client + from tests.conftest import seed_account + + seed_account(fresh_app, _GOOD_JWT, name="resp-chain") + r1 = await client.post("/v1/responses", + json={"model": "GLM-5.3", "input": "第一轮问题"}) + assert r1.status_code == 200 + resp_id = r1.json()["id"] + + r2 = await client.post( + "/v1/responses", + json={"model": "GLM-5.3", "input": "第二轮问题", + "previous_response_id": resp_id}, + ) + assert r2.status_code == 200 + assert r2.json()["id"] != resp_id + payload = json.loads(mock.state.calls[-1][3]) + msgs = payload["messages"] + # 第一轮 user + 第一轮 assistant 输出 + 第二轮 user + assert len(msgs) == 3 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"][0]["text"] == "第一轮问题" + assert msgs[1]["role"] == "assistant" + assert msgs[1]["content"][0]["text"] == "Hello from mock upstream" + assert msgs[2]["role"] == "user" + assert msgs[2]["content"][0]["text"] == "第二轮问题" + + async def test_invalid_payload_rejected(self, gateway_client, fresh_app): + client, _ = gateway_client + res = await client.post("/v1/responses", json={"input": "hi"}) # 缺 model + assert res.status_code == 400 + assert res.json()["error"]["type"] == "invalid_request_error" + + async def test_unknown_previous_response_id_rejected(self, gateway_client, fresh_app): + client, _ = gateway_client + res = await client.post( + "/v1/responses", + json={"model": "GLM-5.3", "input": "hi", + "previous_response_id": "resp_does_not_exist"}, + ) + assert res.status_code == 400 + assert res.json()["error"]["type"] == "invalid_request_error" + + async def test_no_account_returns_503(self, fresh_app): + from httpx import ASGITransport, AsyncClient + + from app.main import create_app + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post("/v1/responses", + json={"model": "glm-5.3", "input": "hi"}) + assert res.status_code == 503 + assert res.json()["error"]["type"] == "no_available_account" + + async def test_gateway_key_required(self, gateway_client, fresh_app): + client, _ = gateway_client + fresh_app.set_setting("gateway_key", "sk-gw-test") + try: + res = await client.post("/v1/responses", + json={"model": "glm-5.3", "input": "hi"}) + assert res.status_code == 401 + res = await client.post("/v1/responses", + headers={"x-api-key": "sk-gw-test"}, + json={"model": "glm-5.3", "input": "hi"}) + assert res.status_code == 503 # 鉴权通过,进入调度(无账号) + finally: + fresh_app.set_setting("gateway_key", "") diff --git a/tests/unit/test_responses_compat.py b/tests/unit/test_responses_compat.py new file mode 100644 index 0000000..34d6057 --- /dev/null +++ b/tests/unit/test_responses_compat.py @@ -0,0 +1,527 @@ +"""responses_compat / response_store 单元测试 —— Responses ↔ Anthropic 双向转换。""" + +from __future__ import annotations + +import json +import re + +from app import response_store +from app.responses_compat import ( + ResponsesStreamConverter, + anthropic_to_responses, + responses_to_anthropic, +) + + +class TestResponsesToAnthropic: + def test_model_required(self): + assert responses_to_anthropic({"input": "hi"})[1] is not None # 缺 model + assert responses_to_anthropic({"model": "", "input": "hi"})[1] is not None + assert responses_to_anthropic({"model": 123, "input": "hi"})[1] is not None + + def test_string_input(self): + body, err = responses_to_anthropic({"model": "GLM-5.3", "input": "你好"}) + assert err is None + assert body["model"] == "GLM-5.3" + assert body["messages"] == [{"role": "user", "content": [{"type": "text", "text": "你好"}]}] + assert body["max_tokens"] == 4096 # Responses 可缺省,Anthropic 必填 + + def test_instructions_to_system(self): + body, err = responses_to_anthropic({"model": "GLM-5.3", "instructions": "守则", "input": "hi"}) + assert err is None + assert body["system"] == "守则" + + def test_user_and_assistant_message_items(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [ + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "问"}]}, + {"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "答"}]}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "再问"}]}, + ], + }) + assert err is None + assert [m["role"] for m in body["messages"]] == ["user", "assistant", "user"] + assert body["messages"][0]["content"] == [{"type": "text", "text": "问"}] + assert body["messages"][1]["content"] == [{"type": "text", "text": "答"}] + + def test_unknown_role_treated_as_user(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "message", "role": "root", + "content": [{"type": "input_text", "text": "x"}]}], + }) + assert body["messages"][0]["role"] == "user" + + def test_input_image_data_url(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "看图"}, + {"type": "input_image", "image_url": "data:image/png;base64,AAAA"}, + ]}], + }) + assert err is None + blocks = body["messages"][0]["content"] + assert blocks[0] == {"type": "text", "text": "看图"} + assert blocks[1]["type"] == "image" + assert blocks[1]["source"] == {"type": "base64", "media_type": "image/png", "data": "AAAA"} + + def test_remote_image_url_skipped(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "看图"}, + {"type": "input_image", "image_url": "https://example.com/a.png"}, + ]}], + }) + blocks = body["messages"][0]["content"] + assert len(blocks) == 1 # 外链图片无法回填,安静跳过 + + def test_function_call_item_to_tool_use(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [ + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "天气如何"}]}, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", + "arguments": "{\"city\":\"杭州\"}"}, + ], + }) + assert err is None + asst = body["messages"][1] + assert asst["role"] == "assistant" + assert asst["content"][0] == {"type": "tool_use", "id": "call_1", "name": "get_weather", + "input": {"city": "杭州"}} + + def test_function_call_invalid_arguments_kept_as_raw(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "function_call", "call_id": "c1", "name": "f", + "arguments": "{bad json"}], + }) + block = body["messages"][0]["content"][0] + assert block["input"] == {"_raw": "{bad json"} + + def test_function_call_output_str(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "function_call_output", "call_id": "call_1", "output": "晴 25 度"}], + }) + msg = body["messages"][0] + assert msg["role"] == "user" + assert msg["content"][0] == {"type": "tool_result", "tool_use_id": "call_1", "content": "晴 25 度"} + + def test_function_call_output_list_parts(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "function_call_output", "call_id": "call_1", "output": [ + {"type": "output_text", "text": "第一段"}, + {"type": "output_text", "text": "第二段"}, + ]}], + }) + assert body["messages"][0]["content"][0]["content"] == "第一段\n第二段" + + def test_reasoning_content_preferred_over_summary(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{ + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": "完整思考"}], + "summary": [{"type": "summary_text", "text": "摘要"}], + }], + }) + block = body["messages"][0]["content"][0] + assert block == {"type": "thinking", "thinking": "完整思考", "signature": ""} + + def test_reasoning_summary_fallback(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{ + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "摘要思考"}], + }], + }) + assert body["messages"][0]["content"][0]["thinking"] == "摘要思考" + + def test_reasoning_empty_skipped(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [ + {"type": "reasoning", "summary": []}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "hi"}]}, + ], + }) + # 空 reasoning 不产生空 assistant 消息 + assert [m["role"] for m in body["messages"]] == ["user"] + + def test_adjacent_same_role_merged(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [ + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "一"}]}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "二"}]}, + ], + }) + # Anthropic 要求 role 交替,相邻同角色合并为一条消息 + assert len(body["messages"]) == 1 + assert body["messages"][0]["content"] == [{"type": "text", "text": "一"}, + {"type": "text", "text": "二"}] + + def test_tools_flat_mapping(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": "hi", + "tools": [ + {"type": "function", "name": "get_weather", "description": "查天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}, + {"type": "web_search", "search_context_size": "low"}, # 内置类型跳过 + ], + }) + assert err is None + assert body["tools"] == [{"name": "get_weather", "description": "查天气", + "input_schema": {"type": "object", + "properties": {"city": {"type": "string"}}}}] + + def test_tools_parameters_default_object(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", "input": "hi", + "tools": [{"type": "function", "name": "f"}], + }) + assert body["tools"][0] == {"name": "f", "description": "", "input_schema": {"type": "object"}} + + def test_tool_choice_variants(self): + base = {"model": "GLM-5.3", "input": "hi", + "tools": [{"type": "function", "name": "f"}]} + body, _ = responses_to_anthropic({**base, "tool_choice": "required"}) + assert body["tool_choice"] == {"type": "any"} + body, _ = responses_to_anthropic({**base, "tool_choice": {"type": "function", "name": "f"}}) + assert body["tool_choice"] == {"type": "tool", "name": "f"} + body, _ = responses_to_anthropic({**base, "tool_choice": "none"}) + assert "tools" not in body and "tool_choice" not in body + body, _ = responses_to_anthropic({**base, "tool_choice": "auto"}) + assert "tool_choice" not in body + + def test_reasoning_effort_mapping(self): + base = {"model": "GLM-5.3", "input": "hi"} + for effort, expected in (("minimal", "low"), ("low", "low"), + ("high", "high"), ("xhigh", "max")): + body, _ = responses_to_anthropic({**base, "reasoning": {"effort": effort}}) + assert body["reasoning_effort"] == expected, effort + # medium 即上游默认档:不带该字段(上游不支持关思考) + body, _ = responses_to_anthropic({**base, "reasoning": {"effort": "medium"}}) + assert "reasoning_effort" not in body + body, _ = responses_to_anthropic(base) + assert "reasoning_effort" not in body + + def test_optional_params_mapping(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", "input": "hi", + "max_output_tokens": 128, "temperature": 0.5, "top_p": 0.9, "stream": True, + }) + assert err is None + assert body["max_tokens"] == 128 + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["stream"] is True + + def test_unknown_fields_quietly_ignored(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", "input": "hi", + "store": True, "include": [], "metadata": {"k": "v"}, + "prompt_cache_key": "ck", "text": {"format": {"type": "text"}}, + }) + assert err is None + assert set(body.keys()) == {"model", "messages", "max_tokens"} + + def test_empty_input_rejected(self): + assert responses_to_anthropic({"model": "GLM-5.3"})[1] == "必须提供 input" + assert responses_to_anthropic({"model": "GLM-5.3", "input": []})[1] == "必须提供 input" + + def test_empty_input_with_previous_response_id_rejected(self): + # 网关层命中历史后会把存储条目合并进 input,翻译器见到空 messages 一律早失败: + # 空数组发上游只会换来语焉不详的 1214「messages 参数非法」 + body, err = responses_to_anthropic({"model": "GLM-5.3", "input": [], + "previous_response_id": "resp_x"}) + assert err is not None and body is None + + def test_chat_shorthand_item_without_type(self): + # 部分客户端的 input 数组发法:{"role":"user","content":"hi"}(无 type)—— + # 曾被当未知条目丢弃、发出空 messages 招致上游 400(实测 code 1214) + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"role": "user", "content": "hi"}, + {"role": "assistant", "content": "yo"}], + }) + assert err is None + assert body["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "yo"}]}, + ] + + def test_chat_shorthand_parts_with_text_type(self): + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"role": "user", + "content": [{"type": "text", "text": "hi"}]}], + }) + assert err is None + assert body["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + ] + + def test_empty_content_message_dropped(self): + # 空内容消息无信息量,整条丢弃而非塞空 text 块(上游对空块同样敏感) + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "message", "role": "user", "content": ""}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "hi"}]}], + }) + assert err is None + assert body["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + ] + + def test_all_items_unusable_rejected(self): + # 条目都映射不出内容 → 本地 400,不把空 messages 发给上游 + body, err = responses_to_anthropic({ + "model": "GLM-5.3", + "input": [{"type": "message", "role": "user", "content": ""}, + {"type": "item_reference", "id": "msg_1"}], + }) + assert err is not None and body is None + + def test_non_dict_items_skipped(self): + body, _ = responses_to_anthropic({ + "model": "GLM-5.3", + "input": ["garbage", 42, {"type": "item_reference", "id": "msg_1"}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "hi"}]}], + }) + assert [m["role"] for m in body["messages"]] == ["user"] + + +class TestAnthropicToResponses: + def test_blocks_to_output_items(self): + out = anthropic_to_responses({ + "id": "msg_1", "type": "message", "model": "GLM-5.3", + "content": [ + {"type": "thinking", "thinking": "先想", "signature": "sig"}, + {"type": "text", "text": "答案"}, + {"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "杭州"}}, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, "GLM-5.3") + assert [o["type"] for o in out["output"]] == ["reasoning", "message", "function_call"] + assert out["output"][0]["summary"] == [{"type": "summary_text", "text": "先想"}] + assert out["output"][1]["role"] == "assistant" + assert out["output"][1]["content"] == [{"type": "output_text", "text": "答案", "annotations": []}] + assert out["output"][2]["call_id"] == "call_1" + assert out["output"][2]["name"] == "get_weather" + assert json.loads(out["output"][2]["arguments"]) == {"city": "杭州"} + + def test_envelope_shape(self): + out = anthropic_to_responses({ + "id": "msg_1", "type": "message", "model": "GLM-5.3", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1}, + }, "GLM-5.3") + assert out["object"] == "response" + assert out["id"].startswith("resp_") + assert out["status"] == "completed" + assert out["model"] == "GLM-5.3" + assert out["error"] is None + assert out["incomplete_details"] is None + assert out["tool_choice"] == "auto" + assert out["tools"] == [] + + def test_max_tokens_incomplete(self): + out = anthropic_to_responses({ + "id": "m", "type": "message", "content": [{"type": "text", "text": "x"}], + "stop_reason": "max_tokens", "usage": {}, + }, "GLM-5.3") + assert out["status"] == "incomplete" + assert out["incomplete_details"] == {"reason": "max_output_tokens"} + + def test_usage_conversion(self): + out = anthropic_to_responses({ + "id": "m", "type": "message", "content": [{"type": "text", "text": "x"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 3}, + }, "GLM-5.3") + assert out["usage"] == { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "input_tokens_details": {"cached_tokens": 3}, + "output_tokens_details": {"reasoning_tokens": 0}, + } + + def test_usage_missing_cache_defaults_zero(self): + out = anthropic_to_responses({ + "id": "m", "type": "message", "content": [], "stop_reason": "end_turn", "usage": {}, + }, "GLM-5.3") + assert out["usage"]["input_tokens_details"] == {"cached_tokens": 0} + assert out["usage"]["total_tokens"] == 0 + + +class TestResponsesStream: + @staticmethod + def _events(): + """手工构造的完整 Anthropic SSE 事件序列(思考 → 文本 → 工具调用)。""" + return [ + {"type": "message_start", "message": {"id": "msg_x", "usage": {"input_tokens": 7}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "想"}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "一下"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "content_block_start", "index": 1, "content_block": {"type": "text"}}, + {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "你"}}, + {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "好"}}, + {"type": "content_block_stop", "index": 1}, + {"type": "content_block_start", "index": 2, + "content_block": {"type": "tool_use", "id": "call_1", "name": "f"}}, + {"type": "content_block_delta", "index": 2, "delta": {"type": "input_json_delta", "partial_json": '{"a"'}}, + {"type": "content_block_delta", "index": 2, "delta": {"type": "input_json_delta", "partial_json": ":1}"}}, + {"type": "content_block_stop", "index": 2}, + {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 9}}, + {"type": "message_stop"}, + ] + + def test_full_event_stream(self): + conv = ResponsesStreamConverter("GLM-5.3") + frames = [conv.start()] + for evt in self._events(): + frames.extend(conv.feed(evt)) + assert conv.done() == "" # Responses 流没有 [DONE] 哨兵 + + types = [json.loads(f.split("data: ", 1)[1])["type"] + for f in "".join(frames).split("\n\n") if f] + assert types[0] == "response.created" + assert types[1] == "response.in_progress" + assert types[-1] == "response.completed" + assert "response.output_item.added" in types and "response.output_item.done" in types + + # sequence_number 从 0 起严格递增 + seqs = [json.loads(f.split("data: ", 1)[1])["sequence_number"] + for f in "".join(frames).split("\n\n") if f] + assert seqs == list(range(len(seqs))) + + def test_text_delta_content(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + frames = [] + for evt in self._events(): + frames.extend(conv.feed(evt)) + raw = "".join(frames) + deltas = [json.loads(m.group(1))["delta"] + for m in re.finditer(r"data: (.*)", raw) + if json.loads(m.group(1))["type"] == "response.output_text.delta"] + assert "".join(deltas) == "你好" + + def test_function_call_arguments_done_accumulated(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + raw = "".join(f for evt in self._events() for f in conv.feed(evt)) + done_frames = [json.loads(m.group(1)) for m in re.finditer(r"data: (.*)", raw) + if json.loads(m.group(1))["type"] == "response.function_call_arguments.done"] + assert len(done_frames) == 1 + assert done_frames[0]["arguments"] == '{"a":1}' + + def test_completed_snapshot(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + raw = "".join(f for evt in self._events() for f in conv.feed(evt)) + completed = [json.loads(m.group(1)) for m in re.finditer(r"data: (.*)", raw) + if json.loads(m.group(1))["type"] == "response.completed"] + assert len(completed) == 1 + snap = completed[0]["response"] + assert snap["status"] == "completed" + assert snap["id"] == "resp_msg_x" # 沿用上游 message id + assert len(snap["output"]) == 3 + assert [o["type"] for o in snap["output"]] == ["reasoning", "message", "function_call"] + assert snap["usage"]["input_tokens"] == 7 + assert snap["usage"]["output_tokens"] == 9 + assert snap["usage"]["total_tokens"] == 16 + + def test_output_items_complete(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + for evt in self._events(): + conv.feed(evt) + assert [o["type"] for o in conv.output_items] == ["reasoning", "message", "function_call"] + assert conv.output_items[0]["summary"][0]["text"] == "想一下" + assert conv.output_items[1]["content"][0]["text"] == "你好" + assert json.loads(conv.output_items[2]["arguments"]) == {"a": 1} + + def test_max_tokens_incomplete_snapshot(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + events = [ + {"type": "message_start", "message": {"id": "msg_m", "usage": {"input_tokens": 1}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "x"}}, + {"type": "message_delta", "delta": {"stop_reason": "max_tokens"}, "usage": {"output_tokens": 1}}, + {"type": "message_stop"}, + ] + raw = "".join(f for evt in events for f in conv.feed(evt)) + completed = [json.loads(m.group(1)) for m in re.finditer(r"data: (.*)", raw) + if json.loads(m.group(1))["type"] == "response.completed"] + snap = completed[0]["response"] + assert snap["status"] == "incomplete" + assert snap["incomplete_details"] == {"reason": "max_output_tokens"} + + def test_unknown_events_ignored(self): + conv = ResponsesStreamConverter("GLM-5.3") + conv.start() + assert conv.feed({"type": "ping"}) == [] + assert conv.feed({"type": "content_block_stop", "index": 0}) == [] # 无在途块,安静忽略 + + +class TestResponseStore: + def setup_method(self): + response_store.clear() + + def test_save_get_roundtrip(self): + response_store.save("resp_a", [{"type": "message", "role": "user"}], + [{"type": "message", "role": "assistant"}]) + st = response_store.get("resp_a") + assert st is not None + assert st["input_items"] == [{"type": "message", "role": "user"}] + assert st["output_items"] == [{"type": "message", "role": "assistant"}] + assert response_store.size() == 1 + + def test_miss_returns_none(self): + assert response_store.get("resp_missing") is None + + def test_get_returns_shallow_copies(self): + inp: list = [{"a": 1}] + response_store.save("resp_b", inp, []) + inp.append({"a": 2}) # 调用方改写原列表不得污染库内条目 + assert response_store.get("resp_b")["input_items"] == [{"a": 1}] + + def test_eviction_after_257(self): + for i in range(257): + response_store.save(f"resp_{i}", [i], []) + assert response_store.size() == 256 + assert response_store.get("resp_0") is None # 最旧被淘汰 + assert response_store.get("resp_1") is not None + assert response_store.get("resp_256") is not None + + def test_get_promotes_lru_order(self): + for i in range(256): + response_store.save(f"resp_{i}", [i], []) + response_store.get("resp_0") # 命中移到队尾,脱离淘汰位次 + response_store.save("resp_new", ["new"], []) + assert response_store.size() == 256 + assert response_store.get("resp_0") is not None # 被提升,幸存 + assert response_store.get("resp_1") is None # 成为最旧,被淘汰 + assert response_store.get("resp_new") is not None