Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Protocol converters for OpenAI-compatible endpoints."""
+307
View File
@@ -0,0 +1,307 @@
from __future__ import annotations
import html
import json
import re
import time
import uuid
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from typing import Any
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI
from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
@dataclass
class MessageRequest:
backend: OpenAIBackendAPI
messages: list[dict[str, Any]]
model: str
tools: Any = None
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
name = str(tool.get("name") or fn.get("name") or "").strip()
desc = str(tool.get("description") or fn.get("description") or "").strip()
schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
return name, desc, schema
def build_tool_prompt(tools: object) -> str:
if not isinstance(tools, list):
return ""
blocks = []
for tool in tools:
if not isinstance(tool, dict):
continue
name, desc, schema = _tool_meta(tool)
if name:
blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
if not blocks:
return ""
return "Available tools:\n" + "\n".join(blocks) + """
Tool use rules:
- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
- To call tools, output ONLY XML and no prose/markdown:
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
- Put parameters under <parameters> using the exact schema names.
""".strip()
def merge_system(system: object, extra: str) -> object:
system = compact_system(system)
if _has_claude_code_system(system):
extra = XML_TOOL_RULE
if not extra:
return system
if isinstance(system, str) and system.strip():
return f"{system.strip()}\n\n{extra}"
if isinstance(system, list):
return [*system, {"type": "text", "text": extra}]
return extra
def _has_claude_code_system(system: object) -> bool:
if isinstance(system, str):
return "You are Claude Code" in system
if isinstance(system, list):
return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
return False
def compact_system(system: object) -> object:
if isinstance(system, str):
return _compact_system_text(system)
if isinstance(system, list):
result = []
for item in system:
if isinstance(item, dict) and str(item.get("type") or "") == "text":
copied = dict(item)
copied["text"] = _compact_system_text(str(item.get("text") or ""))
result.append(copied)
else:
result.append(item)
return result
return system
def _compact_system_text(text: str) -> str:
return text or ""
def _compact_message_text(text: str) -> str:
return text or ""
def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
return payload
def message_request(body: dict[str, Any]) -> MessageRequest:
payload = preprocess_payload(dict(body))
model = str(payload.get("model") or "auto").strip() or "auto"
return MessageRequest(
backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token(model=model)),
messages=normalize_messages(payload.get("messages"), payload.get("system")),
model=model,
tools=payload.get("tools"),
)
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
if not isinstance(messages, list):
return messages
mapper = text_mapper or (lambda text: text)
result = []
for message in messages:
if not isinstance(message, dict):
continue
item = dict(message)
content = item.get("content")
if isinstance(content, str):
item["content"] = _compact_message_text(mapper(content))
elif isinstance(content, list):
item["content"] = [_preprocess_block(block, mapper) for block in content]
result.append(item)
return result
def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
if not isinstance(block, dict):
return block
block_type = str(block.get("type") or "")
if block_type == "text":
item = dict(block)
item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
return item
if block_type == "tool_use":
return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
if block_type == "tool_result":
return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
return block
def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
content, stop_reason = content_blocks(text, tools)
return {
"id": f"msg_{uuid.uuid4()}",
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
}
def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
text = strip_tool_markup(text)
if calls:
content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
return content, "tool_use"
return [{"type": "text", "text": text}], "end_turn"
def strip_tool_markup(text: str) -> str:
return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
def streamable_text(text: str) -> str:
text = text or ""
match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
return text[:match.start()].rstrip() if match else text
def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
text = re.sub(r"(?is)```.*?```", "", text or "").strip()
blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
result = []
for block in (next((part for part in match if part), "") for match in blocks):
name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
if name:
result.append((name, parse_tool_params(params)))
return result
def xml_value(text: str, tag: str) -> str:
match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
if not match:
return ""
value = match.group(1).strip()
cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
return html.unescape(cdata.group(1) if cdata else value).strip()
def parse_tool_params(raw: str) -> dict[str, object]:
raw = raw.strip()
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
def parse_tool_value(raw: str) -> object:
value = xml_value(f"<x>{raw}</x>", "x")
try:
return json.loads(value)
except Exception:
return value
def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
message_id = f"msg_{uuid.uuid4()}"
created = int(time.time())
current_text = ""
streamed_text = ""
tool_mode = isinstance(tools, list) and bool(tools)
tool_started = False
text_open = False
yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
if not tool_mode:
text_open = True
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
for chunk in chunks:
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
if text_delta:
current_text += text_delta
if not tool_started:
visible_text = current_text if not tool_mode else streamable_text(current_text)
if visible_text.startswith(streamed_text):
text_delta = visible_text[len(streamed_text):]
if text_delta:
if not text_open:
text_open = True
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
streamed_text = visible_text
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
tool_started = tool_mode and visible_text != current_text
if choice.get("finish_reason"):
content, stop_reason = content_blocks(current_text, tools)
if text_open:
yield {"type": "content_block_stop", "index": 0}
if stop_reason == "tool_use":
start_index = 1 if text_open else 0
if content and content[0]["type"] == "text":
remaining = str(content[0].get("text") or "")[len(streamed_text):]
if remaining:
if not text_open:
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
if not text_open:
yield {"type": "content_block_stop", "index": 0}
start_index = 1
content = content[1:]
yield from _stream_buffered_blocks(content, start_index)
yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
break
yield {"type": "message_stop", "created": created}
def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
for offset, block in enumerate(content):
index = start_index + offset
if block["type"] == "tool_use":
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
else:
start = {"type": "text", "text": ""}
delta = {"type": "text_delta", "text": block.get("text") or ""}
yield {"type": "content_block_start", "index": index, "content_block": start}
yield {"type": "content_block_delta", "index": index, "delta": delta}
yield {"type": "content_block_stop", "index": index}
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
request = message_request(body)
if body.get("stream"):
return stream_events(
stream_text_chat_completion(request.backend, request.messages, request.model),
request.model,
count_message_tokens(request.messages, request.model),
lambda text: count_text_tokens(text, request.model),
request.tools,
)
text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
return message_response(
request.model,
text,
count_message_tokens(request.messages, request.model),
count_text_tokens(text, request.model),
request.tools,
)
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import copy
import hashlib
import json
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Iterator
from services.config import config
CACHEABLE_TEXT_KEYS = {
"frequency_penalty",
"max_completion_tokens",
"max_tokens",
"metadata",
"model",
"presence_penalty",
"reasoning_effort",
"response_format",
"seed",
"stop",
"temperature",
"thinking_effort",
"tool_choice",
"tools",
"top_p",
"user",
"reasoning",
}
@dataclass
class CacheEntry:
expires_at: float
value: Any
@dataclass
class InflightCall:
condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
done: bool = False
value: Any = None
error: BaseException | None = None
def _json_safe(value: Any) -> Any:
if isinstance(value, bytes):
return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
if isinstance(value, bytearray):
data = bytes(value)
return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
payload["messages"] = messages
payload["stream"] = bool(stream)
return payload
def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
encoded = json.dumps(
_json_safe(canonical_body(body, messages, stream=stream)),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _message_signature(message: dict[str, Any]) -> str:
return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
settings = config.get_chat_completion_cache_settings()
if not settings.get("normalize_messages"):
return messages
normalized: list[dict[str, Any]] = []
previous_signature = ""
for message in messages:
if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
continue
signature = _message_signature(message)
if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
continue
normalized.append(message)
previous_signature = signature
return normalized
class ChatCompletionCache:
def __init__(self) -> None:
self._lock = threading.RLock()
self._entries: dict[str, CacheEntry] = {}
self._inflight: dict[str, InflightCall] = {}
def clear(self) -> None:
with self._lock:
self._entries.clear()
self._inflight.clear()
def _settings(self) -> dict[str, object]:
return config.get_chat_completion_cache_settings()
def _prune_locked(self, now: float, max_entries: int) -> None:
expired = [key for key, item in self._entries.items() if item.expires_at <= now]
for key in expired:
self._entries.pop(key, None)
while len(self._entries) > max_entries:
oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
self._entries.pop(oldest_key, None)
@staticmethod
def _copy(value: Any) -> Any:
return copy.deepcopy(value)
def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
settings = self._settings()
if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
return compute()
now = time.time()
max_entries = int(settings.get("max_entries") or 1)
with self._lock:
self._prune_locked(now, max_entries)
entry = self._entries.get(key)
if entry and entry.expires_at > now:
return self._copy(entry.value)
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
if inflight is None:
inflight = InflightCall()
if settings.get("dedupe_inflight"):
self._inflight[key] = inflight
owner = True
else:
owner = False
if not owner:
with inflight.condition:
while not inflight.done:
inflight.condition.wait()
if inflight.error:
raise inflight.error
return self._copy(inflight.value)
try:
value = compute()
except BaseException as exc:
with self._lock:
self._inflight.pop(key, None)
with inflight.condition:
inflight.error = exc
inflight.done = True
inflight.condition.notify_all()
raise
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
with self._lock:
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
self._prune_locked(time.time(), max_entries)
self._inflight.pop(key, None)
with inflight.condition:
inflight.value = self._copy(value)
inflight.done = True
inflight.condition.notify_all()
return value
def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
settings = self._settings()
if (
not settings.get("enabled")
or not settings.get("stream_cache")
or int(settings.get("ttl_seconds") or 0) <= 0
):
yield from compute()
return
now = time.time()
max_entries = int(settings.get("max_entries") or 1)
with self._lock:
self._prune_locked(now, max_entries)
entry = self._entries.get(key)
if entry and entry.expires_at > now:
yield from self._copy(entry.value)
return
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
if inflight is None:
inflight = InflightCall()
if settings.get("dedupe_inflight"):
self._inflight[key] = inflight
owner = True
else:
owner = False
if not owner:
with inflight.condition:
while not inflight.done:
inflight.condition.wait()
if inflight.error:
raise inflight.error
yield from self._copy(inflight.value)
return
chunks: list[dict[str, Any]] = []
try:
for chunk in compute():
chunks.append(self._copy(chunk))
yield chunk
except BaseException as exc:
with self._lock:
self._inflight.pop(key, None)
with inflight.condition:
inflight.error = exc
inflight.done = True
inflight.condition.notify_all()
raise
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
with self._lock:
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
self._prune_locked(time.time(), max_entries)
self._inflight.pop(key, None)
with inflight.condition:
inflight.value = self._copy(chunks)
inflight.done = True
inflight.condition.notify_all()
chat_completion_cache = ChatCompletionCache()
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from typing import Any
from fastapi.responses import JSONResponse
def _message_from_value(value: object) -> str:
if isinstance(value, str):
return value
if not isinstance(value, dict):
return ""
message = value.get("message")
if isinstance(message, str) and message:
return message
return _message_from_value(value.get("error"))
def error_message_from_detail(detail: object) -> str:
if isinstance(detail, list):
messages = []
for item in detail:
if not isinstance(item, dict):
continue
location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
message = str(item.get("msg") or "").strip()
if location and message:
messages.append(f"{location}: {message}")
elif message:
messages.append(message)
return "; ".join(messages)
if isinstance(detail, dict):
message = _message_from_value(detail.get("error")) or _message_from_value(detail)
if message:
return message
return str(detail or "").strip()
def _default_error_type(status_code: int) -> str:
if status_code == 401:
return "authentication_error"
if status_code == 403:
return "permission_error"
if status_code == 429:
return "rate_limit_error"
if 400 <= status_code < 500:
return "invalid_request_error"
return "server_error"
def _default_error_code(status_code: int) -> str:
if status_code == 401:
return "invalid_api_key"
if status_code == 403:
return "permission_denied"
if status_code == 429:
return "rate_limit_exceeded"
if 400 <= status_code < 500:
return "bad_request"
return "upstream_error"
def openai_error_payload(
detail: object,
status_code: int,
*,
error_type: str | None = None,
code: object | None = None,
param: object | None = None,
) -> dict[str, Any]:
error_detail = detail.get("error") if isinstance(detail, dict) else None
if isinstance(error_detail, dict):
return {
"error": {
"message": error_message_from_detail(error_detail) or "request failed",
"type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
"param": error_detail.get("param", param),
"code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
}
}
return {
"error": {
"message": error_message_from_detail(detail) or "request failed",
"type": error_type or _default_error_type(status_code),
"param": param,
"code": code if code is not None else _default_error_code(status_code),
}
}
def openai_error_response(
detail: object,
status_code: int,
*,
headers: dict[str, str] | None = None,
error_type: str | None = None,
code: object | None = None,
param: object | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
headers=headers,
)
def anthropic_error_response(
detail: object,
status_code: int,
*,
headers: dict[str, str] | None = None,
) -> JSONResponse:
error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
return JSONResponse(
status_code=status_code,
content={
"type": "error",
"error": {
"type": error_type,
"message": error_message_from_detail(detail) or "request failed",
},
},
headers=headers,
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI, SEARCH_MODEL
MODEL = SEARCH_MODEL
def handle(body: dict[str, object]) -> dict[str, object]:
token = account_service.get_text_access_token()
account = account_service.get_account(token) or {}
backend = OpenAIBackendAPI(token)
try:
result = backend.search(str(body["prompt"]))
finally:
backend.close()
account_service.mark_text_used(token)
result["_account_email"] = str(account.get("email") or "")
return result
@@ -0,0 +1,316 @@
from __future__ import annotations
import time
import uuid
from typing import Any, Iterable, Iterator
from fastapi import HTTPException
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
from services.protocol.conversation import (
ConversationRequest,
ImageOutput,
collect_image_outputs,
collect_text,
count_message_image_tokens,
count_message_text_tokens,
count_text_tokens,
encode_images,
normalize_messages,
stream_image_outputs_with_pool,
stream_text_deltas,
text_backend,
)
from services.protocol.web_search_tool import (
WEB_SEARCH_TOOL_TYPES,
has_unsupported_tools,
is_web_search_chat_request,
run_web_search,
search_query_from_messages,
text_with_url_citations,
)
from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
from utils.image_tokens import (
chat_usage_from_image_usage,
count_image_inputs_tokens,
count_image_output_items_tokens,
image_usage,
)
TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
"This compatibility backend cannot execute local tools, shell commands, non-search tools, "
"or file operations. Do not claim to have run tools or inspected external resources. "
"If a user asks you to use a tool, say that tool execution is unavailable through this backend."
)
def normalize_thinking_effort(value: object) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"", "none"}:
return ""
if normalized in {"low", "medium", "high"}:
return normalized
if normalized in {"xhigh", "extended"}:
return "extended"
return ""
def thinking_effort_from_body(body: dict[str, Any]) -> str:
if "thinking_effort" in body:
return normalize_thinking_effort(body.get("thinking_effort"))
if "reasoning_effort" in body:
return normalize_thinking_effort(body.get("reasoning_effort"))
reasoning = body.get("reasoning")
if isinstance(reasoning, dict):
return normalize_thinking_effort(reasoning.get("effort"))
return ""
def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
return {
"id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion.chunk",
"created": created or int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
def completion_response(
model: str,
content: str,
created: int | None = None,
messages: list[dict[str, Any]] | None = None,
annotations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0
prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0
prompt_tokens = prompt_text_tokens + prompt_image_tokens
completion_tokens = count_text_tokens(content, model) if messages else 0
message = {"role": "assistant", "content": content}
if annotations:
message["annotations"] = annotations
return {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": created or int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"prompt_tokens_details": {
"text_tokens": prompt_text_tokens,
"image_tokens": prompt_image_tokens,
"cached_tokens": 0,
},
"completion_tokens_details": {
"text_tokens": completion_tokens,
"image_tokens": 0,
"reasoning_tokens": 0,
},
},
}
def stream_text_chat_completion(
backend,
messages: list[dict[str, Any]],
model: str,
thinking_effort: str = "",
) -> Iterator[dict[str, Any]]:
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
sent_role = False
request = ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)
for delta_text in stream_text_deltas(backend, request):
if not sent_role:
sent_role = True
yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
else:
yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
if not sent_role:
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
parts: list[str] = []
for chunk in chunks:
choices = chunk.get("choices")
first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
content = str(delta.get("content") or "")
if content:
parts.append(content)
return "".join(parts)
def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
messages = body.get("messages")
if isinstance(messages, list) and messages:
return [message for message in messages if isinstance(message, dict)]
prompt = str(body.get("prompt") or "").strip()
if prompt:
return [{"role": "user", "content": prompt}]
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
prompt = extract_chat_prompt(body)
if not prompt:
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
images = [
(data, f"image_{idx}.png", mime)
for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
]
return model, prompt, parse_image_count(body.get("n")), images
def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
if has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
return model, messages
def chat_completion_annotations(annotations: list[dict[str, Any]]) -> list[dict[str, Any]]:
output = []
for item in annotations:
if item.get("type") != "url_citation":
continue
output.append({
"type": "url_citation",
"url_citation": {
"start_index": item.get("start_index", 0),
"end_index": item.get("end_index", 0),
"url": item.get("url", ""),
"title": item.get("title", ""),
},
})
return output
def web_search_chat_response(messages: list[dict[str, Any]], model: str) -> dict[str, Any]:
query = search_query_from_messages(messages)
if not query:
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required for web search"})
text, annotations = text_with_url_citations(run_web_search(query))
return completion_response(
model,
text,
messages=messages,
annotations=chat_completion_annotations(annotations),
)
def stream_web_search_chat_completion(messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
query = search_query_from_messages(messages)
if not query:
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required for web search"})
text, _annotations = text_with_url_citations(run_web_search(query))
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
yield completion_chunk(model, {"role": "assistant", "content": text}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def image_result_content(result: dict[str, Any]) -> str:
data = result.get("data")
if isinstance(data, list) and data:
return build_chat_image_markdown_content(result)
return str(result.get("message") or "Image generation completed.")
def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
model, prompt, n, images = chat_image_args(body)
result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
response_format="b64_json",
images=encode_images(images) or None,
)))
response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
usage = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=count_image_inputs_tokens(images, model),
output_tokens=count_image_output_items_tokens(result.get("data")),
)
response["usage"] = chat_usage_from_image_usage(usage)
return response
def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
model, prompt, n, images = chat_image_args(body)
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
response_format="b64_json",
images=encode_images(images) or None,
))
yield from stream_image_chat_completion(image_outputs, model)
def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
sent_role = False
sent_text = ""
for output in image_outputs:
content = ""
if output.kind == "progress":
content = output.text
sent_text += content
elif output.kind == "result":
content = build_chat_image_markdown_content({"data": output.data})
elif output.kind == "message":
content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
if not content:
continue
if not sent_role:
sent_role = True
yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
else:
yield completion_chunk(model, {"content": content}, None, completion_id, created)
if not sent_role:
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
if body.get("stream"):
if is_image_chat_request(body):
return image_chat_events(body)
model, messages = text_chat_parts(body)
if is_web_search_chat_request(body) and not has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
return stream_web_search_chat_completion(messages, model)
thinking_effort = thinking_effort_from_body(body)
key = cache_key(body, messages, stream=True)
return chat_completion_cache.get_or_compute_stream(
key,
lambda: stream_text_chat_completion(text_backend(model), messages, model, thinking_effort),
)
if is_image_chat_request(body):
return image_chat_response(body)
model, messages = text_chat_parts(body)
if is_web_search_chat_request(body) and not has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
return web_search_chat_response(messages, model)
thinking_effort = thinking_effort_from_body(body)
key = cache_key(body, messages, stream=False)
return chat_completion_cache.get_or_compute_response(
key,
lambda: completion_response(
model,
collect_text(text_backend(model), ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)),
messages=messages,
),
)
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
from io import BytesIO
from typing import Any, Iterator
from PIL import Image
from services.protocol.conversation import (
ConversationRequest,
ImageGenerationError,
collect_image_outputs,
count_text_tokens,
encode_images,
stream_image_chunks,
stream_image_outputs_with_pool,
)
from utils.image_tokens import count_image_inputs_tokens, count_image_output_items_tokens, image_usage
def _composite_mask(
images: list[tuple[bytes, str, str]],
masks: list[tuple[bytes, str, str]],
) -> list[tuple[bytes, str, str]]:
"""将 mask 的 alpha 通道合成到图片中,标识需要编辑的区域。
mask 的透明区域(低 alpha)= 需要编辑的区域,
mask 的不透明区域(高 alpha)= 保留的区域。
如果无 mask 则返回原图。
"""
if not masks:
return images
result: list[tuple[bytes, str, str]] = []
for i, (data, filename, mime_type) in enumerate(images):
mask_data = masks[i][0] if i < len(masks) else masks[-1][0]
img = Image.open(BytesIO(data)).convert("RGBA")
mask_img = Image.open(BytesIO(mask_data))
if mask_img.mode == "RGBA":
alpha = mask_img.split()[3]
elif mask_img.mode == "L":
alpha = mask_img
else:
alpha = mask_img.convert("L")
alpha = alpha.resize(img.size, Image.LANCZOS)
img.putalpha(alpha)
buf = BytesIO()
img.save(buf, format="PNG")
result.append((buf.getvalue(), filename, "image/png"))
return result
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
prompt = str(body.get("prompt") or "")
images = body.get("images") or []
masks = body.get("mask") or []
images = _composite_mask(images, masks)
model = str(body.get("model") or "gpt-image-2")
n = int(body.get("n") or 1)
size = body.get("size")
quality = str(body.get("quality") or "auto")
response_format = str(body.get("response_format") or "b64_json")
base_url = str(body.get("base_url") or "") or None
progress_callback = body.get("progress_callback")
encoded_images = encode_images(images)
if not encoded_images:
raise ImageGenerationError("image is required")
outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
size=size,
quality=quality,
response_format=response_format,
base_url=base_url,
images=encoded_images,
message_as_error=True,
progress_callback=progress_callback,
))
if body.get("stream"):
return stream_image_chunks(outputs)
result = collect_image_outputs(outputs)
result["usage"] = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=count_image_inputs_tokens(images, model),
output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
)
return result
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any, Iterator
from services.protocol.conversation import (
ConversationRequest,
collect_image_outputs,
count_text_tokens,
stream_image_chunks,
stream_image_outputs_with_pool,
)
from utils.image_tokens import count_image_output_items_tokens, image_usage
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
prompt = str(body.get("prompt") or "")
model = str(body.get("model") or "gpt-image-2")
n = int(body.get("n") or 1)
size = body.get("size")
quality = str(body.get("quality") or "auto")
response_format = str(body.get("response_format") or "b64_json")
base_url = str(body.get("base_url") or "") or None
progress_callback = body.get("progress_callback")
outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
size=size,
quality=quality,
response_format=response_format,
base_url=base_url,
message_as_error=True,
progress_callback=progress_callback,
))
if body.get("stream"):
return stream_image_chunks(outputs)
result = collect_image_outputs(outputs)
result["usage"] = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
)
return result
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from services.account_service import account_service
from services.model_service import model_catalog_service
from utils.helper import CODEX_IMAGE_MODEL
def list_models() -> dict[str, Any]:
result = model_catalog_service.list_models()
data = result.get("data")
if not isinstance(data, list):
return result
seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
dynamic_models: set[str] = set()
accounts = account_service.list_accounts()
web_image_accounts = [
account
for account in accounts
if isinstance(account, dict)
]
codex_types = {
normalized
for account in accounts
if isinstance(account, dict)
and account_service._normalize_source_type(account.get("source_type")) == "codex"
and (normalized := account_service._normalize_account_type(account.get("type")))
}
if web_image_accounts:
dynamic_models.add("gpt-image-2")
if codex_types & {"Plus", "Team", "Pro"}:
dynamic_models.add(CODEX_IMAGE_MODEL)
if "Plus" in codex_types:
dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
if "Team" in codex_types:
dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
if "Pro" in codex_types:
dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}")
for model in sorted(dynamic_models):
if model not in seen:
data.append({
"id": model,
"object": "model",
"created": 0,
"owned_by": "chatgpt2api",
"permission": [],
"root": model,
"parent": None,
})
return result
+453
View File
@@ -0,0 +1,453 @@
from __future__ import annotations
import time
import uuid
from typing import Any, Iterable, Iterator
from fastapi import HTTPException
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
from services.protocol.conversation import (
ConversationRequest,
ImageOutput,
count_message_image_tokens,
count_message_text_tokens,
count_text_tokens,
encode_images,
normalize_messages,
stream_image_outputs_with_pool,
stream_text_deltas,
text_backend,
)
from services.protocol.web_search_tool import (
WEB_SEARCH_TOOL_TYPES,
has_unsupported_tools,
has_web_search_tool,
normalized_sources,
run_web_search,
search_query_from_messages,
text_with_url_citations,
)
from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
from utils.image_tokens import (
count_image_content_tokens,
count_image_output_items_tokens,
image_usage,
token_usage,
)
TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
"This compatibility backend cannot execute local tools, shell commands, non-search tools, "
"or file operations. Do not claim to have run tools or inspected external resources. "
"If a user asks you to use a tool, say that tool execution is unavailable through this backend."
)
RESPONSE_CONTENT_PART_TYPES = {"text", "input_text", "output_text", "image_url", "input_image", "image"}
def normalize_thinking_effort(value: object) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"", "none"}:
return ""
if normalized in {"low", "medium", "high"}:
return normalized
if normalized in {"xhigh", "extended"}:
return "extended"
return ""
def thinking_effort_from_body(body: dict[str, Any]) -> str:
reasoning = body.get("reasoning")
if isinstance(reasoning, dict):
return normalize_thinking_effort(reasoning.get("effort"))
if "thinking_effort" in body:
return normalize_thinking_effort(body.get("thinking_effort"))
if "reasoning_effort" in body:
return normalize_thinking_effort(body.get("reasoning_effort"))
return ""
def is_text_response_request(body: dict[str, Any]) -> bool:
return not has_response_image_generation_tool(body)
def has_unsupported_response_tools(body: dict[str, Any]) -> bool:
return has_unsupported_tools(body, {"image_generation", *WEB_SEARCH_TOOL_TYPES})
def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
for tool in body.get("tools") or []:
if isinstance(tool, dict) and tool.get("type") == "image_generation":
return tool
return {}
def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
if isinstance(input_value, dict):
if str(input_value.get("type") or "").strip() == "input_image":
images = extract_image_from_message_content([input_value])
return images[0] if images else None
images = extract_image_from_message_content(input_value.get("content"))
return images[0] if images else None
if not isinstance(input_value, list):
return None
for item in reversed(input_value):
if isinstance(item, dict):
if str(item.get("type") or "").strip() == "input_image":
images = extract_image_from_message_content([item])
if images:
return images[0]
images = extract_image_from_message_content(item.get("content"))
if images:
return images[0]
return None
def _input_image_parts(input_value: object) -> list[dict[str, Any]]:
parts: list[dict[str, Any]] = []
if isinstance(input_value, dict):
content = input_value.get("content")
if isinstance(content, list):
parts.extend(item for item in content if isinstance(item, dict))
return parts
if not isinstance(input_value, list):
return parts
if all(isinstance(item, dict) and item.get("type") for item in input_value):
return [item for item in input_value if isinstance(item, dict)]
for item in input_value:
if isinstance(item, dict):
content = item.get("content")
if isinstance(content, list):
parts.extend(part for part in content if isinstance(part, dict))
return parts
def _is_response_content_part(value: object) -> bool:
if not isinstance(value, dict):
return False
part_type = str(value.get("type") or "").strip()
return part_type in RESPONSE_CONTENT_PART_TYPES or ("image_url" in value and part_type != "message")
def _message_content_from_response_item(item: dict[str, Any]) -> object:
content = item.get("content")
if isinstance(content, list):
return [dict(part) if isinstance(part, dict) else part for part in content]
if isinstance(content, str):
return content
return extract_response_prompt([item]) or content or ""
def _append_response_message(messages: list[dict[str, Any]], role: object, content: object) -> None:
if isinstance(content, str):
if content.strip():
messages.append({"role": str(role or "user"), "content": content.strip()})
return
if isinstance(content, list) and content:
messages.append({"role": str(role or "user"), "content": content})
def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
system_text = str(instructions or "").strip()
if system_text:
messages.append({"role": "system", "content": system_text})
if isinstance(input_value, str):
if input_value.strip():
messages.append({"role": "user", "content": input_value.strip()})
return messages
if isinstance(input_value, dict):
if _is_response_content_part(input_value):
_append_response_message(messages, "user", [dict(input_value)])
return messages
_append_response_message(
messages,
input_value.get("role") or "user",
_message_content_from_response_item(input_value),
)
return messages
if isinstance(input_value, list):
if all(_is_response_content_part(item) for item in input_value):
_append_response_message(messages, "user", [dict(item) for item in input_value if isinstance(item, dict)])
return messages
pending_parts: list[dict[str, Any]] = []
for item in input_value:
if _is_response_content_part(item):
pending_parts.append(dict(item))
continue
if pending_parts:
_append_response_message(messages, "user", pending_parts)
pending_parts = []
if not isinstance(item, dict):
continue
_append_response_message(
messages,
item.get("role") or "user",
_message_content_from_response_item(item),
)
if pending_parts:
_append_response_message(messages, "user", pending_parts)
return messages
def text_output_item(
text: str,
item_id: str | None = None,
status: str = "completed",
annotations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
"id": item_id or f"msg_{uuid.uuid4().hex}",
"type": "message",
"status": status,
"role": "assistant",
"content": [{"type": "output_text", "text": text, "annotations": annotations or []}],
}
def web_search_call_item(
query: str,
item_id: str | None = None,
status: str = "completed",
sources: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"type": "search",
"query": query,
"queries": [query],
}
if sources:
action["sources"] = [
{"type": "url", "url": source["url"]}
for source in sources
if source.get("url")
]
return {
"id": item_id or f"ws_{uuid.uuid4().hex}",
"type": "web_search_call",
"status": status,
"action": action,
}
def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
output = []
for item in data:
b64_json = str(item.get("b64_json") or "").strip()
if b64_json:
output.append({
"id": item_id or f"ig_{len(output) + 1}",
"type": "image_generation_call",
"status": "completed",
"result": b64_json,
"revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
})
return output
def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
return {
"type": "response.created",
"response": {
"id": response_id,
"object": "response",
"created_at": created,
"status": "in_progress",
"error": None,
"incomplete_details": None,
"model": model,
"output": [],
"parallel_tool_calls": False,
},
}
def response_completed(
response_id: str,
model: str,
created: int,
output: list[dict[str, Any]],
usage: dict[str, Any] | None = None,
) -> dict[str, Any]:
response = {
"type": "response.completed",
"response": {
"id": response_id,
"object": "response",
"created_at": created,
"status": "completed",
"error": None,
"incomplete_details": None,
"model": model,
"output": output,
"parallel_tool_calls": False,
},
}
if usage:
response["response"]["usage"] = usage
return response
def text_response_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = normalize_text_messages(normalize_messages(messages_from_input(body.get("input"), body.get("instructions"))))
if has_unsupported_response_tools(body):
messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
return model, messages
def stream_text_response(backend, body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
thinking_effort = thinking_effort_from_body(body)
response_id = f"resp_{uuid.uuid4().hex}"
item_id = f"msg_{uuid.uuid4().hex}"
created = int(time.time())
full_text = ""
yield response_created(response_id, model, created)
yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
request = ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)
for delta in stream_text_deltas(backend, request):
full_text += delta
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
item = text_output_item(full_text, item_id, "completed")
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
usage = token_usage(
input_text_tokens=count_message_text_tokens(messages, model),
input_image_tokens=count_message_image_tokens(messages, model),
output_text_tokens=count_text_tokens(full_text, model),
)
yield response_completed(response_id, model, created, [item], usage)
def stream_web_search_response(body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
query = search_query_from_messages(messages) or extract_response_prompt(body.get("input"))
if not query:
raise HTTPException(status_code=400, detail={"error": "input text is required for web_search"})
response_id = f"resp_{uuid.uuid4().hex}"
search_id = f"ws_{uuid.uuid4().hex}"
item_id = f"msg_{uuid.uuid4().hex}"
created = int(time.time())
yield response_created(response_id, model, created)
searching_item = web_search_call_item(query, search_id, "in_progress")
yield {"type": "response.output_item.added", "output_index": 0, "item": searching_item}
yield {"type": "response.web_search_call.in_progress", "output_index": 0, "item_id": search_id}
yield {"type": "response.web_search_call.searching", "output_index": 0, "item_id": search_id}
result = run_web_search(query)
search_item = web_search_call_item(query, search_id, "completed", normalized_sources(result))
yield {"type": "response.web_search_call.completed", "output_index": 0, "item_id": search_id}
yield {"type": "response.output_item.done", "output_index": 0, "item": search_item}
text, annotations = text_with_url_citations(result)
message_item = text_output_item("", item_id, "in_progress", annotations)
yield {"type": "response.output_item.added", "output_index": 1, "item": message_item}
if text:
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 1, "content_index": 0, "delta": text}
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 1, "content_index": 0, "text": text}
message_item = text_output_item(text, item_id, "completed", annotations)
yield {"type": "response.output_item.done", "output_index": 1, "item": message_item}
usage = token_usage(
input_text_tokens=count_message_text_tokens(messages, model),
input_image_tokens=count_message_image_tokens(messages, model),
output_text_tokens=count_text_tokens(text, model),
)
yield response_completed(response_id, model, created, [search_item, message_item], usage)
def stream_image_response(
image_outputs: Iterable[ImageOutput],
prompt: str,
model: str,
input_image_tokens: int = 0,
size: object = None,
quality: str = "auto",
) -> Iterator[dict[str, Any]]:
response_id = f"resp_{uuid.uuid4().hex}"
created = int(time.time())
yield response_created(response_id, model, created)
for output in image_outputs:
if output.kind == "message":
text = output.text
item = text_output_item(text)
usage = token_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=input_image_tokens,
output_text_tokens=count_text_tokens(text, model),
)
yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
yield response_completed(response_id, model, created, [item], usage)
return
if output.kind != "result":
continue
items = image_output_items(prompt, output.data)
if items:
usage = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=input_image_tokens,
output_tokens=count_image_output_items_tokens(output.data, size, quality),
)
for output_index, item in enumerate(items):
yield {"type": "response.output_item.done", "output_index": output_index, "item": item}
yield response_completed(response_id, model, created, items, usage)
return
raise RuntimeError("image generation failed")
def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
completed = {}
for event in events:
if event.get("type") == "response.completed":
completed = event.get("response") if isinstance(event.get("response"), dict) else {}
if not completed:
raise RuntimeError("response generation failed")
return completed
def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
if is_text_response_request(body):
model, messages = text_response_parts(body)
if has_web_search_tool(body) and not has_unsupported_response_tools(body):
yield from stream_web_search_response(body, messages)
return
key = cache_key(body, messages, stream=bool(body.get("stream")))
yield from chat_completion_cache.get_or_compute_stream(
key,
lambda: stream_text_response(text_backend(model), body, messages),
)
return
prompt = extract_response_prompt(body.get("input"))
if not prompt:
raise HTTPException(status_code=400, detail={"error": "input text is required"})
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
image_info = extract_response_image(body.get("input"))
if image_info:
image_data, mime_type = image_info
images = encode_images([(image_data, "image.png", mime_type)])
else:
images = None
input_image_tokens = count_image_content_tokens(_input_image_parts(body.get("input")), model)
tool = response_image_tool(body)
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
size=tool.get("size"),
quality=str(tool.get("quality") or "auto"),
response_format="b64_json",
images=images,
))
yield from stream_image_response(image_outputs, prompt, model, input_image_tokens, tool.get("size"), str(tool.get("quality") or "auto"))
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
events = response_events(body)
if body.get("stream"):
return events
return collect_response(events)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import re
from typing import Any
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI
WEB_SEARCH_TOOL_TYPES = {"web_search", "web_search_preview", "web_search_preview_2025_03_11"}
SEARCH_CHAT_MODEL_PREFIXES = (
"gpt-4o-search-preview",
"gpt-4o-mini-search-preview",
"gpt-5-search-api",
)
def _tool_type(tool: object) -> str:
return str(tool.get("type") or "").strip() if isinstance(tool, dict) else ""
def has_web_search_tool(body: dict[str, Any]) -> bool:
tools = body.get("tools")
if isinstance(tools, list):
return any(_tool_type(tool) in WEB_SEARCH_TOOL_TYPES for tool in tools)
tool_choice = body.get("tool_choice")
return _tool_type(tool_choice) in WEB_SEARCH_TOOL_TYPES
def is_web_search_chat_request(body: dict[str, Any]) -> bool:
model = str(body.get("model") or "").strip()
return (
has_web_search_tool(body)
or isinstance(body.get("web_search_options"), dict)
or any(
model == prefix or model.startswith(f"{prefix}-")
for prefix in SEARCH_CHAT_MODEL_PREFIXES
)
)
def has_unsupported_tools(body: dict[str, Any], allowed_types: set[str]) -> bool:
tools = body.get("tools")
if not isinstance(tools, list):
return False
return any(_tool_type(tool) not in allowed_types for tool in tools if isinstance(tool, dict))
def message_text(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, str):
text = item.strip()
elif isinstance(item, dict):
text = str(item.get("text") or item.get("input_text") or "").strip()
else:
text = ""
if text:
parts.append(text)
return "\n".join(parts).strip()
return ""
def search_query_from_messages(messages: list[dict[str, Any]]) -> str:
for message in reversed(messages):
if str(message.get("role") or "").strip().lower() != "user":
continue
text = message_text(message.get("content"))
if text:
return text
return ""
def _readable_annotation_part(parts: list[str]) -> str:
for part in parts:
value = part.strip()
lower = value.lower()
if value and not (
lower.startswith(("turn", "source", "sources"))
or re.fullmatch(r"\d+", value)
):
return value
return ""
def clean_search_text(text: str) -> str:
def replace_annotation(match: re.Match[str]) -> str:
parts = [part.strip() for part in match.group(1).split("\ue202")]
kind = (parts[0] if parts else "").lower()
data = parts[1:]
if kind == "url":
label = data[0] if data else ""
url = data[1] if len(data) > 1 else ""
if label and url.startswith(("http://", "https://")):
return f"{label} ({url})"
return label or url
if kind == "cite":
return _readable_annotation_part(data)
return _readable_annotation_part(data)
text = re.sub(r"\ue200([^\ue201]*)\ue201", replace_annotation, text)
text = re.sub(r"\ue200[^\ue201]*$", "", text)
return re.sub(r"\s+([.,;:!?])", r"\1", text).strip()
def normalized_sources(result: dict[str, Any]) -> list[dict[str, str]]:
sources = result.get("sources")
if not isinstance(sources, list):
return []
output: list[dict[str, str]] = []
seen: set[str] = set()
for item in sources:
if not isinstance(item, dict):
continue
url = str(item.get("url") or "").strip()
title = str(item.get("title") or "").strip()
snippet = str(item.get("snippet") or "").strip()
if not url or url in seen:
continue
seen.add(url)
output.append({"title": title, "url": url, "snippet": snippet})
return output
def text_with_url_citations(result: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
text = clean_search_text(str(result.get("answer") or ""))
annotations: list[dict[str, Any]] = []
sources = normalized_sources(result)
if sources:
text = text.rstrip()
if text:
text += "\n\n"
text += "Sources:\n"
for index, source in enumerate(sources, start=1):
title = source["title"] or source["url"]
line_prefix = f"{index}. {title}"
text += line_prefix
if source["url"]:
if source["title"]:
text += " - "
start = len(text)
text += source["url"]
annotations.append({
"type": "url_citation",
"start_index": start,
"end_index": len(text),
"url": source["url"],
"title": source["title"] or source["url"],
})
text += "\n"
return text.strip(), annotations
def run_web_search(query: str) -> dict[str, Any]:
token = account_service.get_text_access_token()
result = OpenAIBackendAPI(token).search(query)
account_service.mark_text_used(token)
return result