2026-09-07 00:15:32 +08:00
|
|
|
"""Persistence and context reconstruction for stateful Responses calls."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from db.models import ConversationMessage, ConversationSession, OpenAIResponse
|
|
|
|
|
from db.session import SessionLocal
|
|
|
|
|
from models import AssistantConfig
|
|
|
|
|
from services.openai_realtime.events import assistant_id_from_model
|
|
|
|
|
from services.openai_responses.protocol import (
|
|
|
|
|
ResponseRequest,
|
|
|
|
|
ResponsesAPIError,
|
|
|
|
|
new_id,
|
|
|
|
|
)
|
|
|
|
|
from services.realtime.launcher import resolve_assistant_config
|
|
|
|
|
from sqlalchemy import func, select, update
|
2026-09-07 14:25:48 +08:00
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-09-07 00:15:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
CONTINUABLE_STATUSES = {"completed"}
|
2026-09-07 14:25:48 +08:00
|
|
|
IDEMPOTENCY_CONSTRAINT = "uq_openai_response_idempotency"
|
2026-09-07 00:15:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class PreparedResponse:
|
|
|
|
|
row: OpenAIResponse
|
|
|
|
|
config: AssistantConfig
|
|
|
|
|
history_messages: list[dict[str, Any]]
|
|
|
|
|
current_messages: list[dict[str, Any]]
|
|
|
|
|
runtime_state: dict[str, Any]
|
|
|
|
|
client_tool_results: list[dict[str, Any]]
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 14:25:48 +08:00
|
|
|
def _integrity_constraint_name(error: IntegrityError) -> str | None:
|
|
|
|
|
current = getattr(error, "orig", None)
|
|
|
|
|
seen: set[int] = set()
|
|
|
|
|
while current is not None and id(current) not in seen:
|
|
|
|
|
seen.add(id(current))
|
|
|
|
|
direct = getattr(current, "constraint_name", None)
|
|
|
|
|
if direct:
|
|
|
|
|
return str(direct)
|
|
|
|
|
diagnostic = getattr(current, "diag", None)
|
|
|
|
|
value = getattr(diagnostic, "constraint_name", None)
|
|
|
|
|
if value:
|
|
|
|
|
return str(value)
|
|
|
|
|
current = getattr(current, "__cause__", None) or getattr(
|
|
|
|
|
current,
|
|
|
|
|
"__context__",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 00:15:32 +08:00
|
|
|
def _content_text(item: dict[str, Any]) -> str:
|
|
|
|
|
return "\n".join(
|
|
|
|
|
str(part.get("text") or "")
|
|
|
|
|
for part in item.get("content") or []
|
|
|
|
|
if isinstance(part, dict)
|
|
|
|
|
).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def items_to_messages(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
|
|
"""Convert the supported Responses item subset to OpenAI LLM messages."""
|
|
|
|
|
messages: list[dict[str, Any]] = []
|
|
|
|
|
pending_calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def flush_calls() -> None:
|
|
|
|
|
if not pending_calls:
|
|
|
|
|
return
|
|
|
|
|
messages.append(
|
|
|
|
|
{"role": "assistant", "content": "", "tool_calls": list(pending_calls)}
|
|
|
|
|
)
|
|
|
|
|
pending_calls.clear()
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
item_type = item.get("type")
|
|
|
|
|
if item_type == "function_call":
|
|
|
|
|
pending_calls.append(
|
|
|
|
|
{
|
|
|
|
|
"id": str(item.get("call_id") or ""),
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": str(item.get("name") or ""),
|
|
|
|
|
"arguments": str(item.get("arguments") or "{}"),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
flush_calls()
|
|
|
|
|
if item_type == "message":
|
|
|
|
|
messages.append(
|
|
|
|
|
{
|
|
|
|
|
"role": str(item.get("role") or "user"),
|
|
|
|
|
"content": _content_text(item),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
elif item_type == "function_call_output":
|
|
|
|
|
messages.append(
|
|
|
|
|
{
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": str(item.get("call_id") or ""),
|
|
|
|
|
"content": str(item.get("output") or ""),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
flush_calls()
|
|
|
|
|
return messages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _load_chain(
|
|
|
|
|
response_id: str,
|
|
|
|
|
*,
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
) -> list[OpenAIResponse]:
|
|
|
|
|
chain: list[OpenAIResponse] = []
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
current_id: str | None = response_id
|
2026-09-07 14:25:48 +08:00
|
|
|
is_requested_response = True
|
2026-09-07 00:15:32 +08:00
|
|
|
while current_id:
|
|
|
|
|
if current_id in seen or len(chain) >= 1000:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Response chain is invalid", code="invalid_response_chain"
|
|
|
|
|
)
|
|
|
|
|
seen.add(current_id)
|
|
|
|
|
row = await db.get(OpenAIResponse, current_id)
|
2026-09-07 14:25:48 +08:00
|
|
|
if (
|
|
|
|
|
row is None
|
|
|
|
|
or row.api_key_id != api_key_id
|
|
|
|
|
or (is_requested_response and row.deleted_at is not None)
|
|
|
|
|
):
|
2026-09-07 00:15:32 +08:00
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Response not found", code="response_not_found", status_code=404
|
|
|
|
|
)
|
|
|
|
|
chain.append(row)
|
|
|
|
|
current_id = row.previous_response_id
|
2026-09-07 14:25:48 +08:00
|
|
|
is_requested_response = False
|
2026-09-07 00:15:32 +08:00
|
|
|
chain.reverse()
|
|
|
|
|
return chain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_tool_outputs(
|
|
|
|
|
chain: list[OpenAIResponse],
|
|
|
|
|
current_items: list[dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
unresolved: dict[str, dict[str, Any]] = {}
|
|
|
|
|
for row in chain:
|
|
|
|
|
for item in row.output_items or []:
|
|
|
|
|
if item.get("type") == "function_call":
|
|
|
|
|
unresolved[str(item.get("call_id") or "")] = item
|
|
|
|
|
for item in row.input_items or []:
|
|
|
|
|
if item.get("type") == "function_call_output":
|
|
|
|
|
unresolved.pop(str(item.get("call_id") or ""), None)
|
|
|
|
|
|
|
|
|
|
supplied = [
|
|
|
|
|
str(item.get("call_id") or "")
|
|
|
|
|
for item in current_items
|
|
|
|
|
if item.get("type") == "function_call_output"
|
|
|
|
|
]
|
|
|
|
|
if len(set(supplied)) != len(supplied):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"A function call output can only be supplied once",
|
|
|
|
|
code="duplicate_tool_output",
|
|
|
|
|
param="input",
|
|
|
|
|
)
|
|
|
|
|
unknown = sorted(set(supplied) - set(unresolved))
|
|
|
|
|
if unknown:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Unknown or already resolved call_id: {unknown[0]}",
|
|
|
|
|
code="invalid_tool_output",
|
|
|
|
|
param="input",
|
|
|
|
|
)
|
|
|
|
|
if unresolved and set(supplied) != set(unresolved):
|
|
|
|
|
missing = sorted(set(unresolved) - set(supplied))
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Missing function_call_output for call_id: {missing[0]}",
|
|
|
|
|
code="missing_tool_output",
|
|
|
|
|
param="input",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client_tool_results(
|
|
|
|
|
chain: list[OpenAIResponse],
|
|
|
|
|
current_items: list[dict[str, Any]],
|
|
|
|
|
) -> list[dict[str, Any]]:
|
2026-09-07 14:25:48 +08:00
|
|
|
calls = [
|
|
|
|
|
item
|
2026-09-07 00:15:32 +08:00
|
|
|
for row in chain
|
|
|
|
|
for item in row.output_items or []
|
|
|
|
|
if item.get("type") == "function_call"
|
2026-09-07 14:25:48 +08:00
|
|
|
]
|
|
|
|
|
outputs = {
|
|
|
|
|
str(item.get("call_id") or ""): item
|
|
|
|
|
for item in current_items
|
|
|
|
|
if item.get("type") == "function_call_output"
|
2026-09-07 00:15:32 +08:00
|
|
|
}
|
|
|
|
|
results: list[dict[str, Any]] = []
|
2026-09-07 14:25:48 +08:00
|
|
|
for call in calls:
|
|
|
|
|
call_id = str(call.get("call_id") or "")
|
|
|
|
|
item = outputs.get(call_id)
|
|
|
|
|
if item is None:
|
2026-09-07 00:15:32 +08:00
|
|
|
continue
|
|
|
|
|
output = str(item.get("output") or "")
|
|
|
|
|
try:
|
|
|
|
|
value = json.loads(output)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
value = output
|
|
|
|
|
if not isinstance(value, dict) or "status" not in value:
|
|
|
|
|
value = {"status": "ok", "data": value}
|
|
|
|
|
results.append(
|
|
|
|
|
{
|
2026-09-07 14:25:48 +08:00
|
|
|
"call_id": call_id,
|
2026-09-07 00:15:32 +08:00
|
|
|
"name": call.get("name"),
|
2026-09-07 14:25:48 +08:00
|
|
|
"arguments": call.get("arguments"),
|
2026-09-07 00:15:32 +08:00
|
|
|
"result": value,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def prepare_response(
|
|
|
|
|
request: ResponseRequest,
|
|
|
|
|
*,
|
|
|
|
|
api_key_id: str,
|
2026-09-07 14:25:48 +08:00
|
|
|
idempotency_key: str | None = None,
|
|
|
|
|
request_hash: str | None = None,
|
2026-09-07 00:15:32 +08:00
|
|
|
) -> PreparedResponse:
|
|
|
|
|
chain: list[OpenAIResponse] = []
|
|
|
|
|
parent: OpenAIResponse | None = None
|
|
|
|
|
if request.previous_response_id:
|
|
|
|
|
chain = await _load_chain(
|
|
|
|
|
request.previous_response_id,
|
|
|
|
|
api_key_id=api_key_id,
|
|
|
|
|
)
|
|
|
|
|
parent = chain[-1]
|
|
|
|
|
if parent.status not in CONTINUABLE_STATUSES:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Previous response is {parent.status}",
|
|
|
|
|
code="response_not_ready",
|
|
|
|
|
param="previous_response_id",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
if (parent.runtime_state or {}).get("status") == "ended":
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"The workflow conversation has ended",
|
|
|
|
|
code="conversation_ended",
|
|
|
|
|
param="previous_response_id",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
if not parent.assistant_id:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"The assistant used by the previous response no longer exists",
|
|
|
|
|
code="invalid_model",
|
|
|
|
|
param="previous_response_id",
|
|
|
|
|
)
|
|
|
|
|
assistant_id = parent.assistant_id
|
|
|
|
|
if request.model:
|
2026-09-07 14:25:48 +08:00
|
|
|
try:
|
|
|
|
|
requested_id = assistant_id_from_model(request.model)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
str(exc),
|
|
|
|
|
code="invalid_model",
|
|
|
|
|
param="model",
|
|
|
|
|
) from exc
|
2026-09-07 00:15:32 +08:00
|
|
|
if requested_id != assistant_id:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"model must match the previous response",
|
|
|
|
|
code="invalid_model",
|
|
|
|
|
param="model",
|
|
|
|
|
)
|
|
|
|
|
conversation_id = parent.conversation_id
|
|
|
|
|
variables = dict(parent.dynamic_variables or {})
|
|
|
|
|
variables.update(request.dynamic_variables)
|
|
|
|
|
_validate_tool_outputs(chain, request.input_items)
|
|
|
|
|
client_tool_results = _client_tool_results(chain, request.input_items)
|
|
|
|
|
runtime_state = dict(parent.runtime_state or {})
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
assistant_id = assistant_id_from_model(request.model)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
str(exc), code="invalid_model", param="model"
|
|
|
|
|
) from exc
|
|
|
|
|
conversation_id = new_id("conv")[:40]
|
|
|
|
|
variables = dict(request.dynamic_variables)
|
|
|
|
|
client_tool_results = []
|
|
|
|
|
runtime_state = {}
|
|
|
|
|
if any(
|
|
|
|
|
item.get("type") == "function_call_output"
|
|
|
|
|
for item in request.input_items
|
|
|
|
|
):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"function_call_output requires previous_response_id",
|
|
|
|
|
code="invalid_tool_output",
|
|
|
|
|
param="input",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
config = await resolve_assistant_config(
|
|
|
|
|
assistant_id,
|
|
|
|
|
dynamic_variables=variables,
|
|
|
|
|
conversation_id=conversation_id,
|
|
|
|
|
)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
str(exc), code="invalid_model", param="model"
|
|
|
|
|
) from exc
|
|
|
|
|
if config.runtimeMode != "pipeline":
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Responses API only supports pipeline assistants",
|
|
|
|
|
code="unsupported_runtime_mode",
|
|
|
|
|
param="model",
|
|
|
|
|
)
|
|
|
|
|
if config.type not in {"prompt", "workflow", "dify", "fastgpt"}:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Assistant type {config.type} is not supported by Responses API",
|
|
|
|
|
code="unsupported_assistant_type",
|
|
|
|
|
param="model",
|
|
|
|
|
)
|
|
|
|
|
if config.conversation_id != conversation_id:
|
|
|
|
|
config = config.model_copy(update={"conversation_id": conversation_id})
|
|
|
|
|
effective_variables = {
|
|
|
|
|
name: value
|
|
|
|
|
for name, value in (config.dynamic_variables or {}).items()
|
|
|
|
|
if not name.startswith(("system__", "secret__"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
history_items: list[dict[str, Any]] = []
|
|
|
|
|
for item in chain:
|
|
|
|
|
history_items.extend(item.input_items or [])
|
|
|
|
|
history_items.extend(item.output_items or [])
|
|
|
|
|
history_messages = (
|
|
|
|
|
list(parent.context_messages or [])
|
|
|
|
|
if parent is not None and parent.context_messages
|
|
|
|
|
else items_to_messages(history_items)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
row = OpenAIResponse(
|
|
|
|
|
id=new_id("resp")[:40],
|
|
|
|
|
api_key_id=api_key_id,
|
|
|
|
|
assistant_id=assistant_id,
|
|
|
|
|
conversation_id=conversation_id,
|
|
|
|
|
previous_response_id=request.previous_response_id,
|
|
|
|
|
status="in_progress",
|
|
|
|
|
input_items=request.input_items,
|
|
|
|
|
output_items=[],
|
|
|
|
|
context_messages=[],
|
|
|
|
|
runtime_state={},
|
|
|
|
|
dynamic_variables=effective_variables,
|
2026-09-07 14:25:48 +08:00
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
request_hash=request_hash,
|
2026-09-07 00:15:32 +08:00
|
|
|
)
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
if parent is None:
|
|
|
|
|
db.add(
|
|
|
|
|
ConversationSession(
|
|
|
|
|
id=conversation_id,
|
|
|
|
|
assistant_id=assistant_id,
|
|
|
|
|
assistant_name=config.name,
|
|
|
|
|
channel="responses",
|
|
|
|
|
runtime_mode="pipeline",
|
|
|
|
|
status="active",
|
|
|
|
|
message_count=0,
|
|
|
|
|
analysis_status="none",
|
|
|
|
|
analysis_data=(
|
|
|
|
|
{"plan": config.analysis_config}
|
|
|
|
|
if config.analysis_config.get("enabled")
|
|
|
|
|
else {}
|
|
|
|
|
),
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
conversation = await db.get(ConversationSession, conversation_id)
|
|
|
|
|
if conversation:
|
|
|
|
|
conversation.status = "active"
|
|
|
|
|
conversation.ended_at = None
|
|
|
|
|
db.add(row)
|
2026-09-07 14:25:48 +08:00
|
|
|
try:
|
|
|
|
|
await db.commit()
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
await db.rollback()
|
|
|
|
|
if (
|
|
|
|
|
idempotency_key is not None
|
|
|
|
|
and _integrity_constraint_name(exc) == IDEMPOTENCY_CONSTRAINT
|
|
|
|
|
):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Idempotency-Key is already in use",
|
|
|
|
|
code="idempotency_key_in_use",
|
|
|
|
|
param="Idempotency-Key",
|
|
|
|
|
status_code=409,
|
|
|
|
|
) from exc
|
|
|
|
|
raise
|
2026-09-07 00:15:32 +08:00
|
|
|
await db.refresh(row)
|
|
|
|
|
|
|
|
|
|
return PreparedResponse(
|
|
|
|
|
row=row,
|
|
|
|
|
config=config,
|
|
|
|
|
history_messages=history_messages,
|
|
|
|
|
current_messages=items_to_messages(request.input_items),
|
|
|
|
|
runtime_state=runtime_state,
|
|
|
|
|
client_tool_results=client_tool_results,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_response(
|
|
|
|
|
response_id: str,
|
|
|
|
|
*,
|
|
|
|
|
api_key_id: str,
|
2026-09-07 14:25:48 +08:00
|
|
|
include_deleted: bool = False,
|
2026-09-07 00:15:32 +08:00
|
|
|
) -> OpenAIResponse:
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
row = await db.get(OpenAIResponse, response_id)
|
2026-09-07 14:25:48 +08:00
|
|
|
if (
|
|
|
|
|
row is None
|
|
|
|
|
or row.api_key_id != api_key_id
|
|
|
|
|
or (row.deleted_at is not None and not include_deleted)
|
|
|
|
|
):
|
2026-09-07 00:15:32 +08:00
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Response not found", code="response_not_found", status_code=404
|
|
|
|
|
)
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 14:25:48 +08:00
|
|
|
async def get_idempotent_response(
|
|
|
|
|
*,
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
idempotency_key: str | None,
|
|
|
|
|
request_hash: str,
|
|
|
|
|
) -> OpenAIResponse | None:
|
|
|
|
|
"""Return a terminal replay or reject conflicting reuse of a request key."""
|
|
|
|
|
|
|
|
|
|
if idempotency_key is None:
|
|
|
|
|
return None
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
row = (
|
|
|
|
|
await db.execute(
|
|
|
|
|
select(OpenAIResponse).where(
|
|
|
|
|
OpenAIResponse.api_key_id == api_key_id,
|
|
|
|
|
OpenAIResponse.idempotency_key == idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
if row.request_hash != request_hash:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Idempotency-Key was already used with a different request",
|
|
|
|
|
code="idempotency_key_conflict",
|
|
|
|
|
param="Idempotency-Key",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
if row.deleted_at is not None:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"The response created by this Idempotency-Key was deleted",
|
|
|
|
|
code="idempotency_response_deleted",
|
|
|
|
|
param="Idempotency-Key",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
if row.status == "in_progress":
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"A request with this Idempotency-Key is still in progress",
|
|
|
|
|
code="idempotency_key_in_use",
|
|
|
|
|
param="Idempotency-Key",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_response(
|
|
|
|
|
response_id: str,
|
|
|
|
|
*,
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
) -> OpenAIResponse:
|
|
|
|
|
"""Create a tombstone while retaining state needed by existing branches."""
|
|
|
|
|
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
row = await db.get(
|
|
|
|
|
OpenAIResponse,
|
|
|
|
|
response_id,
|
|
|
|
|
with_for_update=True,
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
row is None
|
|
|
|
|
or row.api_key_id != api_key_id
|
|
|
|
|
or row.deleted_at is not None
|
|
|
|
|
):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Response not found", code="response_not_found", status_code=404
|
|
|
|
|
)
|
|
|
|
|
if row.status == "in_progress":
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"An in-progress response cannot be deleted",
|
|
|
|
|
code="response_not_ready",
|
|
|
|
|
status_code=409,
|
|
|
|
|
)
|
|
|
|
|
row.deleted_at = datetime.now(UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(row)
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 00:15:32 +08:00
|
|
|
async def finish_response(
|
|
|
|
|
response_id: str,
|
|
|
|
|
*,
|
|
|
|
|
status: str,
|
|
|
|
|
output_items: list[dict[str, Any]],
|
|
|
|
|
context_messages: list[dict[str, Any]] | None = None,
|
|
|
|
|
dynamic_variables: dict[str, Any] | None = None,
|
|
|
|
|
runtime_state: dict[str, Any] | None = None,
|
|
|
|
|
error: dict[str, Any] | None = None,
|
2026-09-07 14:25:48 +08:00
|
|
|
usage: dict[str, Any] | None = None,
|
2026-09-07 00:15:32 +08:00
|
|
|
) -> OpenAIResponse:
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
row = await db.get(OpenAIResponse, response_id)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise RuntimeError(f"Response disappeared while running: {response_id}")
|
|
|
|
|
row.status = status
|
|
|
|
|
row.output_items = output_items
|
|
|
|
|
row.context_messages = context_messages or []
|
|
|
|
|
if dynamic_variables is not None:
|
|
|
|
|
row.dynamic_variables = dynamic_variables
|
|
|
|
|
if runtime_state is not None:
|
|
|
|
|
row.runtime_state = runtime_state
|
|
|
|
|
row.error = error
|
2026-09-07 14:25:48 +08:00
|
|
|
row.usage = usage
|
2026-09-07 00:15:32 +08:00
|
|
|
row.completed_at = now
|
|
|
|
|
|
|
|
|
|
conversation = (
|
|
|
|
|
await db.execute(
|
|
|
|
|
select(ConversationSession)
|
|
|
|
|
.where(ConversationSession.id == row.conversation_id)
|
|
|
|
|
.with_for_update()
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if conversation:
|
|
|
|
|
max_sequence = await db.scalar(
|
|
|
|
|
select(func.max(ConversationMessage.sequence)).where(
|
|
|
|
|
ConversationMessage.session_id == row.conversation_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
sequence = int(max_sequence or 0)
|
|
|
|
|
for item in row.input_items or []:
|
|
|
|
|
if item.get("type") != "message" or item.get("role") != "user":
|
|
|
|
|
continue
|
|
|
|
|
text = _content_text(item)
|
|
|
|
|
if not text:
|
|
|
|
|
continue
|
|
|
|
|
sequence += 1
|
|
|
|
|
db.add(
|
|
|
|
|
ConversationMessage(
|
|
|
|
|
id=new_id("msg")[:40],
|
|
|
|
|
session_id=row.conversation_id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
role="user",
|
|
|
|
|
content_type="text",
|
|
|
|
|
content=text,
|
|
|
|
|
extra={"response_id": row.id},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for item in output_items:
|
|
|
|
|
if item.get("type") == "message":
|
|
|
|
|
text = _content_text(item)
|
|
|
|
|
elif item.get("type") == "function_call":
|
|
|
|
|
text = f"[function_call] {item.get('name') or ''}"
|
|
|
|
|
else:
|
|
|
|
|
continue
|
|
|
|
|
if not text:
|
|
|
|
|
continue
|
|
|
|
|
sequence += 1
|
|
|
|
|
db.add(
|
|
|
|
|
ConversationMessage(
|
|
|
|
|
id=new_id("msg")[:40],
|
|
|
|
|
session_id=row.conversation_id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
role="assistant",
|
|
|
|
|
content_type="text",
|
|
|
|
|
content=text,
|
|
|
|
|
extra={"response_id": row.id, "item": item},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
conversation.message_count = sequence
|
|
|
|
|
conversation.status = "completed" if status == "completed" else status
|
|
|
|
|
conversation.ended_at = now
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(row)
|
|
|
|
|
return row
|
2026-09-07 14:25:48 +08:00
|
|
|
|
|
|
|
|
|
2026-09-07 00:15:32 +08:00
|
|
|
async def recover_interrupted_responses() -> None:
|
|
|
|
|
"""Mark generations interrupted by a process restart as cancelled."""
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
async with SessionLocal() as db:
|
|
|
|
|
conversation_ids = select(OpenAIResponse.conversation_id).where(
|
|
|
|
|
OpenAIResponse.status == "in_progress"
|
|
|
|
|
)
|
|
|
|
|
await db.execute(
|
|
|
|
|
update(ConversationSession)
|
|
|
|
|
.where(ConversationSession.id.in_(conversation_ids))
|
|
|
|
|
.values(status="cancelled", ended_at=now)
|
|
|
|
|
)
|
|
|
|
|
await db.execute(
|
|
|
|
|
update(OpenAIResponse)
|
|
|
|
|
.where(OpenAIResponse.status == "in_progress")
|
|
|
|
|
.values(
|
|
|
|
|
status="cancelled",
|
|
|
|
|
error={
|
|
|
|
|
"code": "server_restarted",
|
|
|
|
|
"message": "Response was interrupted by a server restart",
|
|
|
|
|
},
|
|
|
|
|
completed_at=now,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await db.commit()
|