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
459 lines
17 KiB
Python
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,
|
|
)
|