Initial project import
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Protocol converters for OpenAI-compatible endpoints."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user