thynome commited on
Commit
a22e810
·
1 Parent(s): e16cef9

Add chat completion cache and request dedupe

Browse files
README.md CHANGED
@@ -115,6 +115,7 @@ environment:
115
  ### 实验性 / 规划中
116
 
117
  - `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
 
118
  - 详细状态说明见:[功能清单](./docs/feature-status.en.md)
119
 
120
  ## 效果展示
 
115
  ### 实验性 / 规划中
116
 
117
  - `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
118
+ - `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整
119
  - 详细状态说明见:[功能清单](./docs/feature-status.en.md)
120
 
121
  ## 效果展示
docs/feature-status.en.md CHANGED
@@ -29,6 +29,7 @@
29
  | Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
30
  | `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
31
  | 流式输出支持 | ✅ | 已实现。 |
 
32
  | 图片尺寸参数 | ❌ | 待实现。 |
33
  | 服务端图片 URL 缓存 | ✅ | 已实现。 |
34
  | `rt_token` 刷新 | ❌ | 待实现。 |
 
29
  | Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
30
  | `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
31
  | 流式输出支持 | ✅ | 已实现。 |
32
+ | 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 |
33
  | 图片尺寸参数 | ❌ | 待实现。 |
34
  | 服务端图片 URL 缓存 | ✅ | 已实现。 |
35
  | `rt_token` 刷新 | ❌ | 待实现。 |
services/config.py CHANGED
@@ -37,6 +37,17 @@ DEFAULT_IMAGE_STORAGE = {
37
  "public_base_url": "",
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def _normalize_bool(value: object, default: bool = False) -> bool:
42
  if isinstance(value, str):
@@ -115,6 +126,43 @@ def _normalize_image_storage_settings(value: object) -> dict[str, object]:
115
  }
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def _validate_image_storage_settings(settings: dict[str, object]) -> None:
119
  if not _normalize_bool(settings.get("enabled"), False):
120
  return
@@ -344,6 +392,7 @@ class ConfigStore:
344
  data["global_system_prompt"] = self.global_system_prompt
345
  data["backup"] = self.get_backup_settings()
346
  data["image_storage"] = self.get_image_storage_settings()
 
347
  data.pop("auth-key", None)
348
  return data
349
 
@@ -358,6 +407,10 @@ class ConfigStore:
358
  if "image_storage" in next_data:
359
  next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
360
  _validate_image_storage_settings(next_data["image_storage"])
 
 
 
 
361
  next_data.pop("backup_state", None)
362
  self.data = next_data
363
  self._save()
@@ -369,6 +422,9 @@ class ConfigStore:
369
  def get_image_storage_settings(self) -> dict[str, object]:
370
  return _normalize_image_storage_settings(self.data.get("image_storage"))
371
 
 
 
 
372
  def get_storage_backend(self) -> StorageBackend:
373
  """获取存储后端实例(单例)"""
374
  if self._storage_backend is None:
 
37
  "public_base_url": "",
38
  }
39
 
40
+ DEFAULT_CHAT_COMPLETION_CACHE = {
41
+ "enabled": True,
42
+ "ttl_seconds": 60,
43
+ "max_entries": 256,
44
+ "dedupe_inflight": True,
45
+ "stream_cache": True,
46
+ "normalize_messages": True,
47
+ "drop_adjacent_duplicates": True,
48
+ "drop_assistant_history": False,
49
+ }
50
+
51
 
52
  def _normalize_bool(value: object, default: bool = False) -> bool:
53
  if isinstance(value, str):
 
126
  }
127
 
128
 
129
+ def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
130
+ source = value if isinstance(value, dict) else {}
131
+ return {
132
+ "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
133
+ "ttl_seconds": _normalize_positive_int(
134
+ source.get("ttl_seconds"),
135
+ int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
136
+ 0,
137
+ ),
138
+ "max_entries": _normalize_positive_int(
139
+ source.get("max_entries"),
140
+ int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
141
+ 1,
142
+ ),
143
+ "dedupe_inflight": _normalize_bool(
144
+ source.get("dedupe_inflight"),
145
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
146
+ ),
147
+ "stream_cache": _normalize_bool(
148
+ source.get("stream_cache"),
149
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
150
+ ),
151
+ "normalize_messages": _normalize_bool(
152
+ source.get("normalize_messages"),
153
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
154
+ ),
155
+ "drop_adjacent_duplicates": _normalize_bool(
156
+ source.get("drop_adjacent_duplicates"),
157
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
158
+ ),
159
+ "drop_assistant_history": _normalize_bool(
160
+ source.get("drop_assistant_history"),
161
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
162
+ ),
163
+ }
164
+
165
+
166
  def _validate_image_storage_settings(settings: dict[str, object]) -> None:
167
  if not _normalize_bool(settings.get("enabled"), False):
168
  return
 
392
  data["global_system_prompt"] = self.global_system_prompt
393
  data["backup"] = self.get_backup_settings()
394
  data["image_storage"] = self.get_image_storage_settings()
395
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
396
  data.pop("auth-key", None)
397
  return data
398
 
 
407
  if "image_storage" in next_data:
408
  next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
409
  _validate_image_storage_settings(next_data["image_storage"])
410
+ if "chat_completion_cache" in next_data:
411
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
412
+ next_data.get("chat_completion_cache")
413
+ )
414
  next_data.pop("backup_state", None)
415
  self.data = next_data
416
  self._save()
 
422
  def get_image_storage_settings(self) -> dict[str, object]:
423
  return _normalize_image_storage_settings(self.data.get("image_storage"))
424
 
425
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
426
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
427
+
428
  def get_storage_backend(self) -> StorageBackend:
429
  """获取存储后端实例(单例)"""
430
  if self._storage_backend is None:
services/protocol/chat_completion_cache.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable, Iterable, Iterator
10
+
11
+ from services.config import config
12
+
13
+ CACHEABLE_TEXT_KEYS = {
14
+ "frequency_penalty",
15
+ "max_completion_tokens",
16
+ "max_tokens",
17
+ "metadata",
18
+ "model",
19
+ "presence_penalty",
20
+ "reasoning_effort",
21
+ "response_format",
22
+ "seed",
23
+ "stop",
24
+ "temperature",
25
+ "tool_choice",
26
+ "tools",
27
+ "top_p",
28
+ "user",
29
+ }
30
+
31
+
32
+ @dataclass
33
+ class CacheEntry:
34
+ expires_at: float
35
+ value: Any
36
+
37
+
38
+ @dataclass
39
+ class InflightCall:
40
+ condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
41
+ done: bool = False
42
+ value: Any = None
43
+ error: BaseException | None = None
44
+
45
+
46
+ def _json_safe(value: Any) -> Any:
47
+ if isinstance(value, bytes):
48
+ return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
49
+ if isinstance(value, bytearray):
50
+ data = bytes(value)
51
+ return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
52
+ if isinstance(value, dict):
53
+ return {str(key): _json_safe(item) for key, item in value.items()}
54
+ if isinstance(value, (list, tuple)):
55
+ return [_json_safe(item) for item in value]
56
+ return value
57
+
58
+
59
+ def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
60
+ payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
61
+ payload["messages"] = messages
62
+ payload["stream"] = bool(stream)
63
+ return payload
64
+
65
+
66
+ def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
67
+ encoded = json.dumps(
68
+ _json_safe(canonical_body(body, messages, stream=stream)),
69
+ ensure_ascii=False,
70
+ sort_keys=True,
71
+ separators=(",", ":"),
72
+ ).encode("utf-8")
73
+ return hashlib.sha256(encoded).hexdigest()
74
+
75
+
76
+ def _message_signature(message: dict[str, Any]) -> str:
77
+ return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
78
+
79
+
80
+ def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
81
+ settings = config.get_chat_completion_cache_settings()
82
+ if not settings.get("normalize_messages"):
83
+ return messages
84
+
85
+ normalized: list[dict[str, Any]] = []
86
+ previous_signature = ""
87
+ for message in messages:
88
+ if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
89
+ continue
90
+ signature = _message_signature(message)
91
+ if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
92
+ continue
93
+ normalized.append(message)
94
+ previous_signature = signature
95
+ return normalized
96
+
97
+
98
+ class ChatCompletionCache:
99
+ def __init__(self) -> None:
100
+ self._lock = threading.RLock()
101
+ self._entries: dict[str, CacheEntry] = {}
102
+ self._inflight: dict[str, InflightCall] = {}
103
+
104
+ def clear(self) -> None:
105
+ with self._lock:
106
+ self._entries.clear()
107
+ self._inflight.clear()
108
+
109
+ def _settings(self) -> dict[str, object]:
110
+ return config.get_chat_completion_cache_settings()
111
+
112
+ def _prune_locked(self, now: float, max_entries: int) -> None:
113
+ expired = [key for key, item in self._entries.items() if item.expires_at <= now]
114
+ for key in expired:
115
+ self._entries.pop(key, None)
116
+ while len(self._entries) > max_entries:
117
+ oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
118
+ self._entries.pop(oldest_key, None)
119
+
120
+ @staticmethod
121
+ def _copy(value: Any) -> Any:
122
+ return copy.deepcopy(value)
123
+
124
+ def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
125
+ settings = self._settings()
126
+ if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
127
+ return compute()
128
+
129
+ now = time.time()
130
+ max_entries = int(settings.get("max_entries") or 1)
131
+ with self._lock:
132
+ self._prune_locked(now, max_entries)
133
+ entry = self._entries.get(key)
134
+ if entry and entry.expires_at > now:
135
+ return self._copy(entry.value)
136
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
137
+ if inflight is None:
138
+ inflight = InflightCall()
139
+ if settings.get("dedupe_inflight"):
140
+ self._inflight[key] = inflight
141
+ owner = True
142
+ else:
143
+ owner = False
144
+
145
+ if not owner:
146
+ with inflight.condition:
147
+ while not inflight.done:
148
+ inflight.condition.wait()
149
+ if inflight.error:
150
+ raise inflight.error
151
+ return self._copy(inflight.value)
152
+
153
+ try:
154
+ value = compute()
155
+ except BaseException as exc:
156
+ with self._lock:
157
+ self._inflight.pop(key, None)
158
+ with inflight.condition:
159
+ inflight.error = exc
160
+ inflight.done = True
161
+ inflight.condition.notify_all()
162
+ raise
163
+
164
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
165
+ with self._lock:
166
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
167
+ self._prune_locked(time.time(), max_entries)
168
+ self._inflight.pop(key, None)
169
+ with inflight.condition:
170
+ inflight.value = self._copy(value)
171
+ inflight.done = True
172
+ inflight.condition.notify_all()
173
+ return value
174
+
175
+ def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
176
+ settings = self._settings()
177
+ if (
178
+ not settings.get("enabled")
179
+ or not settings.get("stream_cache")
180
+ or int(settings.get("ttl_seconds") or 0) <= 0
181
+ ):
182
+ yield from compute()
183
+ return
184
+
185
+ now = time.time()
186
+ max_entries = int(settings.get("max_entries") or 1)
187
+ with self._lock:
188
+ self._prune_locked(now, max_entries)
189
+ entry = self._entries.get(key)
190
+ if entry and entry.expires_at > now:
191
+ yield from self._copy(entry.value)
192
+ return
193
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
194
+ if inflight is None:
195
+ inflight = InflightCall()
196
+ if settings.get("dedupe_inflight"):
197
+ self._inflight[key] = inflight
198
+ owner = True
199
+ else:
200
+ owner = False
201
+
202
+ if not owner:
203
+ with inflight.condition:
204
+ while not inflight.done:
205
+ inflight.condition.wait()
206
+ if inflight.error:
207
+ raise inflight.error
208
+ yield from self._copy(inflight.value)
209
+ return
210
+
211
+ chunks: list[dict[str, Any]] = []
212
+ try:
213
+ for chunk in compute():
214
+ chunks.append(self._copy(chunk))
215
+ yield chunk
216
+ except BaseException as exc:
217
+ with self._lock:
218
+ self._inflight.pop(key, None)
219
+ with inflight.condition:
220
+ inflight.error = exc
221
+ inflight.done = True
222
+ inflight.condition.notify_all()
223
+ raise
224
+
225
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
226
+ with self._lock:
227
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
228
+ self._prune_locked(time.time(), max_entries)
229
+ self._inflight.pop(key, None)
230
+ with inflight.condition:
231
+ inflight.value = self._copy(chunks)
232
+ inflight.done = True
233
+ inflight.condition.notify_all()
234
+
235
+
236
+ chat_completion_cache = ChatCompletionCache()
services/protocol/conversation.py CHANGED
@@ -246,6 +246,7 @@ class ConversationRequest:
246
  @dataclass
247
  class ConversationState:
248
  text: str = ""
 
249
  conversation_id: str = ""
250
  file_ids: list[str] = field(default_factory=list)
251
  sediment_ids: list[str] = field(default_factory=list)
@@ -312,7 +313,27 @@ def strip_history(text: str, history_text: str = "") -> str:
312
  return text
313
 
314
 
315
- def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  for candidate in (event, event.get("v")):
317
  if not isinstance(candidate, dict):
318
  continue
@@ -328,6 +349,10 @@ def assistant_text(event: dict[str, Any], current_text: str = "", history_text:
328
  return apply_text_patch(event, current_text, history_text)
329
 
330
 
 
 
 
 
331
  def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
332
  for candidate in (event, event.get("v")):
333
  if not isinstance(candidate, dict):
@@ -486,9 +511,12 @@ def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
486
  update_conversation_state(state, payload, event)
487
  if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
488
  history_index += 1
 
489
  state.text = ""
490
  continue
491
- next_text = assistant_text(event, state.text, history_text)
 
 
492
  if next_text != state.text:
493
  delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
494
  state.text = next_text
 
246
  @dataclass
247
  class ConversationState:
248
  text: str = ""
249
+ raw_text: str = ""
250
  conversation_id: str = ""
251
  file_ids: list[str] = field(default_factory=list)
252
  sediment_ids: list[str] = field(default_factory=list)
 
313
  return text
314
 
315
 
316
+ def sanitize_output_text(text: str) -> str:
317
+ text = str(text or "")
318
+
319
+ def replace_url(match: re.Match[str]) -> str:
320
+ label = match.group(1).strip()
321
+ url = match.group(2).strip()
322
+ if label and url.startswith(("http://", "https://")):
323
+ return f"{label} ({url})"
324
+ return label or url
325
+
326
+ # ChatGPT web sometimes returns rich annotation markers using private-use
327
+ # characters. API clients cannot render those, so convert links and drop
328
+ # citations before emitting OpenAI-compatible text.
329
+ text = re.sub(r"\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201", replace_url, text)
330
+ text = re.sub(r"\ue200cite\ue202[^\ue201]*\ue201", "", text)
331
+ text = re.sub(r"\ue200[^\ue201]*\ue201", "", text)
332
+ text = re.sub(r"\ue200[^\ue201]*$", "", text)
333
+ return text
334
+
335
+
336
+ def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
337
  for candidate in (event, event.get("v")):
338
  if not isinstance(candidate, dict):
339
  continue
 
349
  return apply_text_patch(event, current_text, history_text)
350
 
351
 
352
+ def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
353
+ return sanitize_output_text(assistant_raw_text(event, current_text, history_text))
354
+
355
+
356
  def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
357
  for candidate in (event, event.get("v")):
358
  if not isinstance(candidate, dict):
 
511
  update_conversation_state(state, payload, event)
512
  if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
513
  history_index += 1
514
+ state.raw_text = ""
515
  state.text = ""
516
  continue
517
+ next_raw_text = assistant_raw_text(event, state.raw_text, history_text)
518
+ next_text = sanitize_output_text(next_raw_text)
519
+ state.raw_text = next_raw_text
520
  if next_text != state.text:
521
  delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
522
  state.text = next_text
services/protocol/openai_v1_chat_complete.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any, Iterable, Iterator
6
 
7
  from fastapi import HTTPException
8
 
 
9
  from services.protocol.conversation import (
10
  ConversationRequest,
11
  ImageOutput,
@@ -28,6 +29,12 @@ from utils.image_tokens import (
28
  image_usage,
29
  )
30
 
 
 
 
 
 
 
31
 
32
  def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
33
  return {
@@ -66,10 +73,12 @@ def completion_response(
66
  "prompt_tokens_details": {
67
  "text_tokens": prompt_text_tokens,
68
  "image_tokens": prompt_image_tokens,
 
69
  },
70
  "completion_tokens_details": {
71
  "text_tokens": completion_tokens,
72
  "image_tokens": 0,
 
73
  },
74
  },
75
  }
@@ -127,7 +136,10 @@ def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[byt
127
 
128
  def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
129
  model = str(body.get("model") or "auto").strip() or "auto"
130
- messages = normalize_messages(chat_messages_from_body(body))
 
 
 
131
  return model, messages
132
 
133
 
@@ -200,9 +212,20 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
200
  if is_image_chat_request(body):
201
  return image_chat_events(body)
202
  model, messages = text_chat_parts(body)
203
- return stream_text_chat_completion(text_backend(), messages, model)
 
 
 
 
204
  if is_image_chat_request(body):
205
  return image_chat_response(body)
206
  model, messages = text_chat_parts(body)
207
- request = ConversationRequest(model=model, messages=messages)
208
- return completion_response(model, collect_text(text_backend(), request), messages=messages)
 
 
 
 
 
 
 
 
6
 
7
  from fastapi import HTTPException
8
 
9
+ from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
10
  from services.protocol.conversation import (
11
  ConversationRequest,
12
  ImageOutput,
 
29
  image_usage,
30
  )
31
 
32
+ TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
33
+ "This compatibility backend cannot execute local tools, shell commands, web searches, "
34
+ "or file operations. Do not claim to have run tools or inspected external resources. "
35
+ "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
36
+ )
37
+
38
 
39
  def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
40
  return {
 
73
  "prompt_tokens_details": {
74
  "text_tokens": prompt_text_tokens,
75
  "image_tokens": prompt_image_tokens,
76
+ "cached_tokens": 0,
77
  },
78
  "completion_tokens_details": {
79
  "text_tokens": completion_tokens,
80
  "image_tokens": 0,
81
+ "reasoning_tokens": 0,
82
  },
83
  },
84
  }
 
136
 
137
  def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
138
  model = str(body.get("model") or "auto").strip() or "auto"
139
+ messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
140
+ tools = body.get("tools")
141
+ if isinstance(tools, list) and tools:
142
+ messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
143
  return model, messages
144
 
145
 
 
212
  if is_image_chat_request(body):
213
  return image_chat_events(body)
214
  model, messages = text_chat_parts(body)
215
+ key = cache_key(body, messages, stream=True)
216
+ return chat_completion_cache.get_or_compute_stream(
217
+ key,
218
+ lambda: stream_text_chat_completion(text_backend(), messages, model),
219
+ )
220
  if is_image_chat_request(body):
221
  return image_chat_response(body)
222
  model, messages = text_chat_parts(body)
223
+ key = cache_key(body, messages, stream=False)
224
+ return chat_completion_cache.get_or_compute_response(
225
+ key,
226
+ lambda: completion_response(
227
+ model,
228
+ collect_text(text_backend(), ConversationRequest(model=model, messages=messages)),
229
+ messages=messages,
230
+ ),
231
+ )
services/protocol/openai_v1_response.py CHANGED
@@ -7,6 +7,7 @@ from typing import Any, Iterable, Iterator
7
 
8
  from fastapi import HTTPException
9
 
 
10
  from services.protocol.conversation import (
11
  ConversationRequest,
12
  ImageOutput,
@@ -25,11 +26,27 @@ from utils.image_tokens import (
25
  token_usage,
26
  )
27
 
 
 
 
 
 
 
28
 
29
  def is_text_response_request(body: dict[str, Any]) -> bool:
30
  return not has_response_image_generation_tool(body)
31
 
32
 
 
 
 
 
 
 
 
 
 
 
33
  def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
34
  for tool in body.get("tools") or []:
35
  if isinstance(tool, dict) and tool.get("type") == "image_generation":
@@ -174,9 +191,17 @@ def response_completed(
174
  return response
175
 
176
 
177
- def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]:
 
 
 
 
 
 
 
 
178
  model = str(body.get("model") or "auto").strip() or "auto"
179
- messages = messages_from_input(body.get("input"), body.get("instructions"))
180
  response_id = f"resp_{uuid.uuid4().hex}"
181
  item_id = f"msg_{uuid.uuid4().hex}"
182
  created = int(time.time())
@@ -250,7 +275,12 @@ def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
250
 
251
  def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
252
  if is_text_response_request(body):
253
- yield from stream_text_response(text_backend(), body)
 
 
 
 
 
254
  return
255
 
256
  prompt = extract_response_prompt(body.get("input"))
 
7
 
8
  from fastapi import HTTPException
9
 
10
+ from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
11
  from services.protocol.conversation import (
12
  ConversationRequest,
13
  ImageOutput,
 
26
  token_usage,
27
  )
28
 
29
+ TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
30
+ "This compatibility backend cannot execute local tools, shell commands, web searches, "
31
+ "or file operations. Do not claim to have run tools or inspected external resources. "
32
+ "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
33
+ )
34
+
35
 
36
  def is_text_response_request(body: dict[str, Any]) -> bool:
37
  return not has_response_image_generation_tool(body)
38
 
39
 
40
+ def has_non_image_tools(body: dict[str, Any]) -> bool:
41
+ tools = body.get("tools")
42
+ if not isinstance(tools, list):
43
+ return False
44
+ return any(
45
+ isinstance(tool, dict) and str(tool.get("type") or "").strip() != "image_generation"
46
+ for tool in tools
47
+ )
48
+
49
+
50
  def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
51
  for tool in body.get("tools") or []:
52
  if isinstance(tool, dict) and tool.get("type") == "image_generation":
 
191
  return response
192
 
193
 
194
+ def text_response_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
195
+ model = str(body.get("model") or "auto").strip() or "auto"
196
+ messages = normalize_text_messages(messages_from_input(body.get("input"), body.get("instructions")))
197
+ if has_non_image_tools(body):
198
+ messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
199
+ return model, messages
200
+
201
+
202
+ def stream_text_response(backend, body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
203
  model = str(body.get("model") or "auto").strip() or "auto"
204
+ messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
205
  response_id = f"resp_{uuid.uuid4().hex}"
206
  item_id = f"msg_{uuid.uuid4().hex}"
207
  created = int(time.time())
 
275
 
276
  def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
277
  if is_text_response_request(body):
278
+ model, messages = text_response_parts(body)
279
+ key = cache_key(body, messages, stream=bool(body.get("stream")))
280
+ yield from chat_completion_cache.get_or_compute_stream(
281
+ key,
282
+ lambda: stream_text_response(text_backend(), body, messages),
283
+ )
284
  return
285
 
286
  prompt = extract_response_prompt(body.get("input"))
test/test_chat_completion_cache.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from unittest import mock
5
+ import json
6
+
7
+ from services.config import config
8
+ from services.protocol import openai_v1_chat_complete, openai_v1_response
9
+ from services.protocol.chat_completion_cache import chat_completion_cache
10
+ from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text
11
+
12
+
13
+ class ChatCompletionCacheTests(unittest.TestCase):
14
+ def setUp(self) -> None:
15
+ self.old_cache_settings = config.data.get("chat_completion_cache")
16
+ config.data["chat_completion_cache"] = {
17
+ "enabled": True,
18
+ "ttl_seconds": 60,
19
+ "max_entries": 32,
20
+ "dedupe_inflight": True,
21
+ "stream_cache": True,
22
+ "normalize_messages": True,
23
+ "drop_adjacent_duplicates": True,
24
+ "drop_assistant_history": False,
25
+ }
26
+ chat_completion_cache.clear()
27
+
28
+ def tearDown(self) -> None:
29
+ if self.old_cache_settings is None:
30
+ config.data.pop("chat_completion_cache", None)
31
+ else:
32
+ config.data["chat_completion_cache"] = self.old_cache_settings
33
+ chat_completion_cache.clear()
34
+
35
+ def test_repeated_non_stream_text_completion_uses_cache(self) -> None:
36
+ calls = 0
37
+
38
+ def fake_collect_text(_backend, _request):
39
+ nonlocal calls
40
+ calls += 1
41
+ return f"cached answer {calls}"
42
+
43
+ body = {
44
+ "model": "auto",
45
+ "messages": [{"role": "user", "content": "cache this exact prompt"}],
46
+ }
47
+
48
+ with (
49
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
50
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
51
+ ):
52
+ first = openai_v1_chat_complete.handle(body)
53
+ second = openai_v1_chat_complete.handle(body)
54
+
55
+ self.assertEqual(calls, 1)
56
+ self.assertEqual(
57
+ first["choices"][0]["message"]["content"],
58
+ second["choices"][0]["message"]["content"],
59
+ )
60
+
61
+ def test_repeated_stream_text_completion_replays_cached_chunks(self) -> None:
62
+ calls = 0
63
+
64
+ def fake_stream_text_deltas(_backend, _request):
65
+ nonlocal calls
66
+ calls += 1
67
+ yield "streamed"
68
+ yield " answer"
69
+
70
+ body = {
71
+ "model": "auto",
72
+ "stream": True,
73
+ "messages": [{"role": "user", "content": "stream cache this exact prompt"}],
74
+ }
75
+
76
+ with (
77
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
78
+ mock.patch(
79
+ "services.protocol.openai_v1_chat_complete.stream_text_deltas",
80
+ side_effect=fake_stream_text_deltas,
81
+ ),
82
+ ):
83
+ first = list(openai_v1_chat_complete.handle(body))
84
+ second = list(openai_v1_chat_complete.handle(body))
85
+
86
+ self.assertEqual(calls, 1)
87
+ self.assertEqual(first, second)
88
+ content = "".join(str(chunk["choices"][0]["delta"].get("content") or "") for chunk in second)
89
+ self.assertEqual(content, "streamed answer")
90
+
91
+ def test_adjacent_duplicate_messages_are_removed_before_upstream_call(self) -> None:
92
+ captured_messages = []
93
+
94
+ def fake_collect_text(_backend, request):
95
+ captured_messages.extend(request.messages or [])
96
+ return "ok"
97
+
98
+ body = {
99
+ "model": "auto",
100
+ "messages": [
101
+ {"role": "user", "content": "repeat me"},
102
+ {"role": "user", "content": "repeat me"},
103
+ {"role": "assistant", "content": "old answer"},
104
+ {"role": "user", "content": "next prompt"},
105
+ ],
106
+ }
107
+
108
+ with (
109
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
110
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
111
+ ):
112
+ openai_v1_chat_complete.handle(body)
113
+
114
+ self.assertEqual(
115
+ captured_messages,
116
+ [
117
+ {"role": "user", "content": "repeat me"},
118
+ {"role": "assistant", "content": "old answer"},
119
+ {"role": "user", "content": "next prompt"},
120
+ ],
121
+ )
122
+
123
+ def test_chat_completion_usage_includes_cached_tokens(self) -> None:
124
+ with (
125
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
126
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", return_value="ok"),
127
+ ):
128
+ response = openai_v1_chat_complete.handle({
129
+ "model": "auto",
130
+ "messages": [{"role": "user", "content": "usage shape"}],
131
+ })
132
+
133
+ details = response["usage"]["prompt_tokens_details"]
134
+ self.assertEqual(details["cached_tokens"], 0)
135
+ output_details = response["usage"]["completion_tokens_details"]
136
+ self.assertEqual(output_details["reasoning_tokens"], 0)
137
+
138
+ def test_responses_completed_usage_includes_cached_tokens(self) -> None:
139
+ with (
140
+ mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
141
+ mock.patch("services.protocol.openai_v1_response.stream_text_deltas", return_value=iter(["ok"])),
142
+ ):
143
+ response = openai_v1_response.handle({
144
+ "model": "auto",
145
+ "input": "usage shape",
146
+ })
147
+
148
+ details = response["usage"]["input_tokens_details"]
149
+ self.assertEqual(details["cached_tokens"], 0)
150
+ output_details = response["usage"]["output_tokens_details"]
151
+ self.assertEqual(output_details["reasoning_tokens"], 0)
152
+
153
+ def test_repeated_responses_text_request_uses_cache(self) -> None:
154
+ calls = 0
155
+
156
+ def fake_stream_text_deltas(_backend, _request):
157
+ nonlocal calls
158
+ calls += 1
159
+ yield f"response cache {calls}"
160
+
161
+ body = {
162
+ "model": "auto",
163
+ "input": "cache this responses prompt",
164
+ "stream": True,
165
+ }
166
+
167
+ with (
168
+ mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
169
+ mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
170
+ ):
171
+ first = list(openai_v1_response.handle(body))
172
+ second = list(openai_v1_response.handle(body))
173
+
174
+ self.assertEqual(calls, 1)
175
+ self.assertEqual(first, second)
176
+
177
+ def test_output_sanitizer_removes_chatgpt_annotation_markup(self) -> None:
178
+ text = (
179
+ "Repo: \ue200url\ue202basketikun/chatgpt2api"
180
+ "\ue202https://github.com/basketikun/chatgpt2api\ue201 "
181
+ "details \ue200cite\ue202turn0search0\ue201."
182
+ )
183
+
184
+ self.assertEqual(
185
+ sanitize_output_text(text),
186
+ "Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details .",
187
+ )
188
+
189
+ def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None:
190
+ events = [
191
+ {"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"},
192
+ {"p": "/message/content/parts/0", "o": "append", "v": "gpt2api\ue202turn0search0\ue201 done \ue200cite\ue202turn0\ue201."},
193
+ "[DONE]",
194
+ ]
195
+ payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
196
+ deltas = [
197
+ str(event.get("delta") or "")
198
+ for event in iter_conversation_payloads(iter(payloads))
199
+ if event.get("type") == "conversation.delta"
200
+ ]
201
+
202
+ self.assertEqual("".join(deltas), "Repo: chatgpt2api done .")
203
+ self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas))
204
+
205
+ def test_responses_tools_add_honest_no_tool_guard(self) -> None:
206
+ model, messages = openai_v1_response.text_response_parts({
207
+ "model": "auto",
208
+ "input": "run echo hi",
209
+ "tools": [{"type": "function", "name": "shell"}],
210
+ })
211
+
212
+ self.assertEqual(model, "auto")
213
+ self.assertEqual(messages[0]["role"], "system")
214
+ self.assertIn("cannot execute local tools", str(messages[0]["content"]))
215
+
216
+
217
+ if __name__ == "__main__":
218
+ unittest.main()
utils/image_tokens.py CHANGED
@@ -302,10 +302,12 @@ def token_usage(
302
  "input_tokens_details": {
303
  "text_tokens": max(0, int(input_text_tokens or 0)),
304
  "image_tokens": max(0, int(input_image_tokens or 0)),
 
305
  },
306
  "output_tokens_details": {
307
  "text_tokens": max(0, int(output_text_tokens or 0)),
308
  "image_tokens": max(0, int(output_image_tokens or 0)),
 
309
  },
310
  }
311
 
@@ -334,9 +336,11 @@ def chat_usage_from_image_usage(usage: dict[str, Any]) -> dict[str, Any]:
334
  "prompt_tokens_details": {
335
  "text_tokens": int(input_details.get("text_tokens") or 0),
336
  "image_tokens": int(input_details.get("image_tokens") or 0),
 
337
  },
338
  "completion_tokens_details": {
339
  "text_tokens": int(output_details.get("text_tokens") or 0),
340
  "image_tokens": int(output_details.get("image_tokens") or 0),
 
341
  },
342
  }
 
302
  "input_tokens_details": {
303
  "text_tokens": max(0, int(input_text_tokens or 0)),
304
  "image_tokens": max(0, int(input_image_tokens or 0)),
305
+ "cached_tokens": 0,
306
  },
307
  "output_tokens_details": {
308
  "text_tokens": max(0, int(output_text_tokens or 0)),
309
  "image_tokens": max(0, int(output_image_tokens or 0)),
310
+ "reasoning_tokens": 0,
311
  },
312
  }
313
 
 
336
  "prompt_tokens_details": {
337
  "text_tokens": int(input_details.get("text_tokens") or 0),
338
  "image_tokens": int(input_details.get("image_tokens") or 0),
339
+ "cached_tokens": int(input_details.get("cached_tokens") or 0),
340
  },
341
  "completion_tokens_details": {
342
  "text_tokens": int(output_details.get("text_tokens") or 0),
343
  "image_tokens": int(output_details.get("image_tokens") or 0),
344
+ "reasoning_tokens": int(output_details.get("reasoning_tokens") or 0),
345
  },
346
  }