Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

459 lines
17 KiB
Python

"""Standard chat completions API routes.
Exposes ``/v1/chat/completions`` and ``/v1/models`` endpoints that
follow the widely-adopted chat completions protocol so external tools
(opencode, continue, etc.) can connect to DocsGPT agents.
"""
import json
import logging
import time
import traceback
from datetime import datetime
from typing import Any, Dict, Generator, Optional
from flask import Blueprint, jsonify, make_response, request, Response
from application.api.answer.routes.base import BaseAnswerResource
from application.api.answer.services.persistence_policy import resolve_persistence
from application.api.answer.services.stream_processor import StreamProcessor
from application.api.v1 import idempotency as v1_idempotency
from application.api.v1.translator import (
translate_request,
translate_response,
translate_stream_event,
)
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
logger = logging.getLogger(__name__)
v1_bp = Blueprint("v1", __name__, url_prefix="/v1")
def _extract_bearer_token() -> Optional[str]:
"""Extract API key from Authorization: Bearer header."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return None
def _lookup_agent(api_key: str) -> Optional[Dict]:
"""Look up the agent document for this API key."""
try:
with db_readonly() as conn:
return AgentsRepository(conn).find_by_key(api_key)
except Exception:
logger.warning("Failed to look up agent for API key", exc_info=True)
return None
def _get_model_name(agent: Optional[Dict], api_key: str) -> str:
"""Return agent name for display as model name."""
if agent:
return agent.get("name", api_key)
return api_key
class _V1AnswerHelper(BaseAnswerResource):
"""Thin wrapper to access complete_stream / process_response_stream."""
pass
@v1_bp.route("/chat/completions", methods=["POST"])
def chat_completions():
"""Handle POST /v1/chat/completions."""
api_key = _extract_bearer_token()
if not api_key:
return make_response(
jsonify({"error": {"message": "Missing Authorization header", "type": "auth_error"}}),
401,
)
data = request.get_json()
if not data or not data.get("messages"):
return make_response(
jsonify({"error": {"message": "messages field is required", "type": "invalid_request"}}),
400,
)
is_stream = data.get("stream", False)
agent_doc = _lookup_agent(api_key)
model_name = _get_model_name(agent_doc, api_key)
# ---- Layer-1 idempotency (opt-in, non-streaming only) ----
# An ``Idempotency-Key`` header makes a retried non-streaming request
# return the stored first response instead of re-running the agent
# (restoring the guard lost when the v1 tool round dropped the native
# ``resume_from_tool_actions`` / ``mark_resuming`` path → would otherwise
# duplicate the answer row and double-bill tokens). Streaming replay is
# intentionally NOT supported (see the ``is_stream`` branch below), so we
# only resolve a key for non-streaming requests. No header → byte-for-byte
# today's behavior.
idem_key: Optional[str] = None
if not is_stream:
raw_key, key_error = v1_idempotency.read_idempotency_key()
if key_error is not None:
return key_error
# Scope per tenant: ``{agent_id}:{key}`` so two agents using the same
# key value never collide. Fall back to api_key scoping when the agent
# has no resolvable id (idempotency still keyed, just per api_key).
agent_scope = None
if agent_doc is not None:
agent_scope = str(agent_doc.get("id") or agent_doc.get("_id") or "") or None
idem_key = v1_idempotency.scoped_key(raw_key, agent_scope or api_key)
try:
internal_data = translate_request(data, api_key)
except Exception as e:
logger.error(f"/v1/chat/completions translate error: {e}", exc_info=True)
return make_response(
jsonify({"error": {"message": "Failed to process request", "type": "invalid_request"}}),
400,
)
# Link decoded_token to the agent's owner so continuation state,
# logs, and tool execution use the correct user identity. The PG
# ``agents`` row exposes the owner via ``user_id`` (``user`` is the
# legacy Mongo field name kept in ``row_to_dict`` only for the
# mapping ``id``/``_id``).
agent_user = (
(agent_doc.get("user_id") or agent_doc.get("user"))
if agent_doc else None
)
decoded_token = {"sub": agent_user or "api_key_user"}
try:
processor = StreamProcessor(internal_data, decoded_token)
if internal_data.get("tool_actions"):
# Continuation mode — coherent Option B: the v1 tool round-trip is
# fully stateless. The pause finalized the prior turn's row as
# ``complete`` and wrote NO ``pending_tool_state`` (see
# ``complete_stream(finalize_tool_pause_as_complete=True)``), so we
# ALWAYS rebuild the agent + pending calls from the re-POSTed
# message history — even when the client threads back the
# ``conversation_id`` it got from the first response.
#
# We deliberately do NOT call ``resume_from_tool_actions`` here:
# its ``load_state`` would find no pending state and raise (→ HTTP
# 400), since OpenAI clients resume statelessly rather than via a
# native resume. ``resume_from_tool_actions`` stays in place for
# the native ``/stream`` + ``/api/answer`` routes, which are
# unchanged.
conversation_id = internal_data.get("conversation_id")
(
agent,
messages,
tools_dict,
pending_tool_calls,
tool_actions,
reasoning_content,
) = processor.build_continuation_from_messages(
internal_data.get("messages", []),
internal_data["tool_actions"],
)
# When a conversation_id is carried, target it for persistence so
# the final answer appends as a NEW terminal turn in that
# conversation (``save_conversation`` keys off ``conversation_id``)
# rather than creating an orphan sibling. ``build_continuation_from_messages``
# leaves the processor's ``conversation_id`` (set from the request
# in ``__init__``) intact; pin it explicitly for clarity.
if conversation_id:
processor.conversation_id = conversation_id
continuation = {
"messages": messages,
"tools_dict": tools_dict,
"pending_tool_calls": pending_tool_calls,
"tool_actions": tool_actions,
"reasoning_content": reasoning_content,
}
question = ""
else:
# Normal mode
question = internal_data.get("question", "")
agent = processor.build_agent(question)
continuation = None
if not processor.decoded_token:
return make_response(
jsonify({"error": {"message": "Unauthorized", "type": "auth_error"}}),
401,
)
helper = _V1AnswerHelper()
usage_error = helper.check_usage(processor.agent_config)
if usage_error:
return usage_error
# v1 always persists (unless the translator opted out for a stateless
# tool round) and never lists in the agent owner's sidebar — only the
# first-party UI opts a conversation into ``visibility: "listed"``.
should_persist, visibility = resolve_persistence(
persist_flag=internal_data.get("persist"),
)
# Only strip leaked reasoning from content for structured requests -- the
# only path where models echo reasoning into content -- so legitimate
# answers that mention the marker text are never corrupted.
strip_reasoning_leak = bool(
internal_data.get("json_schema") or internal_data.get("json_object")
)
if is_stream:
# Idempotency replay is NOT supported for streaming: there is no
# safe way to re-emit a recorded SSE stream (and the regression /
# b2b client is non-streaming), so a streaming request never
# claims a key. This is a known, accepted limitation.
return Response(
_stream_response(
helper,
question,
agent,
processor,
model_name,
continuation,
should_persist,
visibility,
strip_reasoning_leak,
),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ---- Non-streaming: claim-before-process, then finalize/release ----
# Claim happens here (after auth + agent resolution + continuation
# build, immediately before running the agent) so a duplicate retry
# short-circuits to the cached body / 409 instead of re-running.
if idem_key:
claimed, replay = v1_idempotency.claim_or_replay(idem_key)
if not claimed:
# ``completed`` cache hit, or a 409 for an in-flight same-key
# request — either way return without re-running the agent.
return replay
# An exception from the agent run propagates to the ``except`` handlers
# below, which release the claim so a genuine retry can re-claim.
response = _non_stream_response(
helper,
question,
agent,
processor,
model_name,
continuation,
should_persist,
visibility,
strip_reasoning_leak,
)
# Cache only successful (2xx) responses; ``finalize`` releases the
# claim on a non-2xx so a real retry can still succeed (matches OpenAI).
if idem_key:
v1_idempotency.finalize(idem_key, response)
return response
except ValueError as e:
if idem_key:
v1_idempotency.release(idem_key)
logger.error(
f"/v1/chat/completions error: {e} - {traceback.format_exc()}",
extra={"error": str(e)},
)
return make_response(
jsonify({"error": {"message": "Failed to process request", "type": "invalid_request"}}),
400,
)
except Exception as e:
if idem_key:
v1_idempotency.release(idem_key)
logger.error(
f"/v1/chat/completions error: {e} - {traceback.format_exc()}",
extra={"error": str(e)},
)
return make_response(
jsonify({"error": {"message": "Internal server error", "type": "server_error"}}),
500,
)
def _stream_response(
helper: _V1AnswerHelper,
question: str,
agent: Any,
processor: StreamProcessor,
model_name: str,
continuation: Optional[Dict],
should_persist: bool,
visibility: str,
strip_reasoning_leak: bool = False,
) -> Generator[str, None, None]:
"""Generate translated SSE chunks for streaming response."""
completion_id = f"chatcmpl-{int(time.time())}"
internal_stream = helper.complete_stream(
question=question,
agent=agent,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,
agent_id=processor.agent_id,
model_id=processor.model_id,
model_user_id=processor.model_user_id,
should_persist=should_persist,
visibility=visibility,
_continuation=continuation,
# OpenAI clients resume tool calls statelessly (no slot for our
# reserved_message_id), so a tool pause must finalize the row as
# ``complete`` here rather than stranding it for a native resume.
finalize_tool_pause_as_complete=True,
)
for line in internal_stream:
if not line.strip():
continue
# ``complete_stream`` prefixes each frame with ``id: <seq>\n``
# before the ``data:`` line. Extract just the data line so JSON
# decode doesn't choke on the SSE framing.
event_str = ""
for raw in line.split("\n"):
if raw.startswith("data:"):
event_str = raw[len("data:") :].lstrip()
break
if not event_str:
continue
try:
event_data = json.loads(event_str)
except (json.JSONDecodeError, TypeError):
continue
# Skip the informational ``message_id`` event — it has no v1 /
# OpenAI-compatible analog.
if event_data.get("type") == "message_id":
continue
# Update completion_id when we get the conversation id
if event_data.get("type") == "id":
conv_id = event_data.get("id", "")
if conv_id and conv_id != "None":
completion_id = f"chatcmpl-{conv_id}"
# Translate to standard format
translated = translate_stream_event(
event_data, completion_id, model_name, strip_reasoning_leak
)
for chunk in translated:
yield chunk
def _non_stream_response(
helper: _V1AnswerHelper,
question: str,
agent: Any,
processor: StreamProcessor,
model_name: str,
continuation: Optional[Dict],
should_persist: bool,
visibility: str,
strip_reasoning_leak: bool = False,
) -> Response:
"""Collect full response and return as single JSON."""
stream = helper.complete_stream(
question=question,
agent=agent,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,
agent_id=processor.agent_id,
model_id=processor.model_id,
model_user_id=processor.model_user_id,
should_persist=should_persist,
visibility=visibility,
_continuation=continuation,
# OpenAI clients resume tool calls statelessly (no slot for our
# reserved_message_id), so a tool pause must finalize the row as
# ``complete`` here rather than stranding it for a native resume.
finalize_tool_pause_as_complete=True,
)
result = helper.process_response_stream(stream)
if result["error"]:
return make_response(
jsonify({"error": {"message": result["error"], "type": "server_error"}}),
500,
)
extra = result.get("extra")
pending = extra.get("pending_tool_calls") if isinstance(extra, dict) else None
response = translate_response(
conversation_id=result["conversation_id"],
answer=result["answer"] or "",
sources=result["sources"],
tool_calls=result["tool_calls"],
thought=result["thought"] or "",
model_name=model_name,
pending_tool_calls=pending,
strip_reasoning_leak=strip_reasoning_leak,
)
return make_response(jsonify(response), 200)
@v1_bp.route("/models", methods=["GET"])
def list_models():
"""Handle GET /v1/models — return agents as models."""
api_key = _extract_bearer_token()
if not api_key:
return make_response(
jsonify({"error": {"message": "Missing Authorization header", "type": "auth_error"}}),
401,
)
try:
with db_readonly() as conn:
agents_repo = AgentsRepository(conn)
agent = agents_repo.find_by_key(api_key)
if not agent:
return make_response(
jsonify({"error": {"message": "Invalid API key", "type": "auth_error"}}),
401,
)
# Repository rows now go through ``coerce_pg_native`` at SELECT
# time, so timestamps arrive as ISO 8601 strings. Parse before
# taking ``.timestamp()``; fall back to ``time.time()`` only when
# the value is genuinely missing or unparseable.
created = agent.get("created_at") or agent.get("createdAt")
if isinstance(created, str):
try:
created = datetime.fromisoformat(created)
except (ValueError, TypeError):
created = None
created_ts = (
int(created.timestamp()) if hasattr(created, "timestamp")
else int(time.time())
)
model_id = str(agent.get("id") or agent.get("_id") or "")
model = {
"id": model_id,
"object": "model",
"created": created_ts,
"owned_by": "docsgpt",
"name": agent.get("name", ""),
"description": agent.get("description", ""),
}
return make_response(
jsonify({"object": "list", "data": [model]}),
200,
)
except Exception as e:
logger.error(f"/v1/models error: {e}", exc_info=True)
return make_response(
jsonify({"error": {"message": "Internal server error", "type": "server_error"}}),
500,
)