From fc89d51b7f1416ff45638b37626b6905dd18b13e Mon Sep 17 00:00:00 2001
From: JK <2081217232@qq.com>
Date: Sun, 4 Oct 2026 16:20:25 +0800
Subject: [PATCH 1/5] =?UTF-8?q?fix(admin):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?=
=?UTF-8?q?=E5=8D=A1=E7=89=87=E6=B0=B4=E5=B9=B3=E5=B1=85=E4=B8=AD=EF=BC=9B?=
=?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E=E8=A1=A5=20OpenAI=20?=
=?UTF-8?q?=E5=8F=8C=E5=8D=8F=E8=AE=AE=E7=AB=AF=E7=82=B9=E4=B8=8E=E7=BD=91?=
=?UTF-8?q?=E5=85=B3=20Key=20=E8=A6=86=E7=9B=96=E8=8C=83=E5=9B=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/admin/settings.html | 10 +++++++---
frontend/css/app.css | 2 +-
frontend/version | 2 +-
3 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/frontend/admin/settings.html b/frontend/admin/settings.html
index 8c26ad5..a4b0b38 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,8 @@
· 在「账号池」粘贴 Coding Plan JWT 或 API Key 即可加入轮询。
· 请求按 round-robin 分发;某账号额度用完会自动切到下一个账号。
· 账号额度、状态在「账号池」页实时刷新展示。
- · 对话端点:/v1/messages(兼容 Anthropic Messages 协议)。
+ · 对话端点(Anthropic 协议):/v1/messages。
+ · 对话端点(OpenAI 协议):/v1/chat/completions;模型列表:/v1/models。
@@ -70,7 +71,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..b8fea86 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -243,7 +243,7 @@ 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{background:var(--panel);border:1px solid var(--hairline-soft);border-radius:14px;padding:22px;margin:0 auto 16px;max-width:640px}
.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..d48d370 100644
--- a/frontend/version
+++ b/frontend/version
@@ -1 +1 @@
-2.6.8
\ No newline at end of file
+2.6.9
From f20bd233c14c5da4a59a09242db324ab60ed2211 Mon Sep 17 00:00:00 2001
From: JK <2081217232@qq.com>
Date: Sun, 4 Oct 2026 16:43:47 +0800
Subject: [PATCH 2/5] =?UTF-8?q?feat(gateway):=20/v1/responses=E2=80=94?=
=?UTF-8?q?=E2=80=94OpenAI=20Responses=20API=20=E5=8F=8C=E5=90=91=E7=BF=BB?=
=?UTF-8?q?=E8=AF=91=EF=BC=88=E6=B5=81=E5=BC=8F/=E9=9D=9E=E6=B5=81?=
=?UTF-8?q?=E5=BC=8F/previous=5Fresponse=5Fid=20LRU=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/response_store.py | 58 +++
app/responses_compat.py | 539 ++++++++++++++++++++
app/routes/gateway.py | 147 +++++-
tests/integration/test_gateway_responses.py | 137 +++++
tests/unit/test_responses_compat.py | 480 +++++++++++++++++
5 files changed, 1360 insertions(+), 1 deletion(-)
create mode 100644 app/response_store.py
create mode 100644 app/responses_compat.py
create mode 100644 tests/integration/test_gateway_responses.py
create mode 100644 tests/unit/test_responses_compat.py
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..f28cef9
--- /dev/null
+++ b/app/responses_compat.py
@@ -0,0 +1,539 @@
+"""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:
+ """type=="message" 条目 → Anthropic 消息。assistant 取 output_text,
+ user(及其他角色按 user)取 input_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") == "output_text"
+ and isinstance(part.get("text"), str) and part["text"]):
+ blocks.append({"type": "text", "text": part["text"]})
+ return {"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]}
+ # 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 == "input_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 or [{"type": "text", "text": ""}]}
+
+
+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")
+ if itype == "message":
+ out_msgs.append(_message_to_blocks(item))
+ 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)
+
+ 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/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..f595608
--- /dev/null
+++ b/tests/unit/test_responses_compat.py
@@ -0,0 +1,480 @@
+"""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_allowed(self):
+ # 网关层会把链入条目合并进 input;直接调用时 previous_response_id 放行空 input
+ body, err = responses_to_anthropic({"model": "GLM-5.3", "input": [],
+ "previous_response_id": "resp_x"})
+ assert err is None
+ assert body["messages"] == []
+
+ 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
From d77e2d8578c79f37331387cbf8541de67deba0ae Mon Sep 17 00:00:00 2001
From: JK <2081217232@qq.com>
Date: Sun, 4 Oct 2026 21:18:48 +0800
Subject: [PATCH 3/5] =?UTF-8?q?fix(gateway):=20/v1/responses=20=E5=85=BC?=
=?UTF-8?q?=E5=AE=B9=E6=97=A0=20type=20=E7=9A=84=20role+content=20?=
=?UTF-8?q?=E7=AE=80=E5=86=99=E6=9D=A1=E7=9B=AE=EF=BC=9B=E7=A9=BA=20messag?=
=?UTF-8?q?es=20=E6=9C=AC=E5=9C=B0=E6=97=A9=E5=A4=B1=E8=B4=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/responses_compat.py | 32 ++++++++++++-----
tests/unit/test_responses_compat.py | 53 +++++++++++++++++++++++++++--
2 files changed, 74 insertions(+), 11 deletions(-)
diff --git a/app/responses_compat.py b/app/responses_compat.py
index f28cef9..23c8576 100644
--- a/app/responses_compat.py
+++ b/app/responses_compat.py
@@ -47,8 +47,16 @@ def _image_block(url: str) -> dict | None:
def _message_to_blocks(item: dict) -> dict | None:
- """type=="message" 条目 → Anthropic 消息。assistant 取 output_text,
- user(及其他角色按 user)取 input_text / input_image(仅 data URL)。"""
+ """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":
@@ -57,10 +65,10 @@ def _message_to_blocks(item: dict) -> dict | None:
blocks.append({"type": "text", "text": content})
elif isinstance(content, list):
for part in content:
- if (isinstance(part, dict) and part.get("type") == "output_text"
+ 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 or [{"type": "text", "text": ""}]}
+ return {"role": "assistant", "content": blocks} if blocks else None
# user 及未知角色一律按 user 处理
blocks = []
if isinstance(content, str) and content:
@@ -70,7 +78,7 @@ def _message_to_blocks(item: dict) -> dict | None:
if not isinstance(part, dict):
continue
ptype = part.get("type")
- if ptype == "input_text" and isinstance(part.get("text"), str) and part["text"]:
+ 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")
@@ -79,7 +87,7 @@ def _message_to_blocks(item: dict) -> dict | None:
block = _image_block(str(url))
if block:
blocks.append(block)
- return {"role": "user", "content": blocks or [{"type": "text", "text": ""}]}
+ return {"role": "user", "content": blocks} if blocks else None
def _reasoning_to_thinking(item: dict) -> dict | None:
@@ -179,8 +187,11 @@ def responses_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
if not isinstance(item, dict):
continue # 非 dict 条目安静跳过
itype = item.get("type")
- if itype == "message":
- out_msgs.append(_message_to_blocks(item))
+ # 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:
@@ -198,6 +209,11 @@ def responses_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
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,
diff --git a/tests/unit/test_responses_compat.py b/tests/unit/test_responses_compat.py
index f595608..34d6057 100644
--- a/tests/unit/test_responses_compat.py
+++ b/tests/unit/test_responses_compat.py
@@ -244,12 +244,59 @@ 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_allowed(self):
- # 网关层会把链入条目合并进 input;直接调用时 previous_response_id 放行空 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"] == []
+ 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({
From eeae907d3d0727d897ba9dd0216e8b077501e2dc Mon Sep 17 00:00:00 2001
From: JK <2081217232@qq.com>
Date: Sun, 4 Oct 2026 21:26:52 +0800
Subject: [PATCH 4/5] =?UTF-8?q?fix(admin):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?=
=?UTF-8?q?=E6=94=B9=E5=85=A8=E5=AE=BD=E5=B8=83=E5=B1=80=EF=BC=9B=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E8=AF=B4=E6=98=8E=E7=AB=AF=E7=82=B9=E8=A1=8C=E9=87=8D?=
=?UTF-8?q?=E6=8E=92=E2=80=94=E2=80=94=E4=B8=89=E4=B8=AA=E7=AB=AF=E7=82=B9?=
=?UTF-8?q?=E8=BF=9E=E7=BB=AD=E3=80=81=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E5=9E=AB=E5=BA=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/admin/settings.html | 5 +++--
frontend/css/app.css | 3 ++-
frontend/version | 2 +-
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/frontend/admin/settings.html b/frontend/admin/settings.html
index a4b0b38..584f91d 100644
--- a/frontend/admin/settings.html
+++ b/frontend/admin/settings.html
@@ -57,8 +57,9 @@
· 在「账号池」粘贴 Coding Plan JWT 或 API Key 即可加入轮询。
· 请求按 round-robin 分发;某账号额度用完会自动切到下一个账号。
· 账号额度、状态在「账号池」页实时刷新展示。
- · 对话端点(Anthropic 协议):/v1/messages。
- · 对话端点(OpenAI 协议):/v1/chat/completions;模型列表:/v1/models。
+ · 对话端点(Anthropic 协议):/v1/messages
+ · 对话端点(OpenAI 协议):/v1/chat/completions
+ · 模型列表:/v1/models
diff --git a/frontend/css/app.css b/frontend/css/app.css
index b8fea86..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:0 auto 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 d48d370..a04abec 100644
--- a/frontend/version
+++ b/frontend/version
@@ -1 +1 @@
-2.6.9
+2.6.10
From cd22aaa9f9b6609e73f5327a4ae0d59f725a479c Mon Sep 17 00:00:00 2001
From: JK <2081217232@qq.com>
Date: Sun, 4 Oct 2026 22:12:14 +0800
Subject: [PATCH 5/5] =?UTF-8?q?chore:=20.gitignore=20=E8=A1=A5=20.zcode/?=
=?UTF-8?q?=EF=BC=88ZCode=20=E4=BC=9A=E8=AF=9D=E4=BA=A7=E7=89=A9=EF=BC=8C?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9C=AC=E5=9C=B0=E6=9D=82=E7=89=A9=E6=B0=B8?=
=?UTF-8?q?=E4=B8=8D=E5=85=A5=E5=BA=93=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
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/