chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from application.api.v1.routes import v1_bp
__all__ = ["v1_bp"]
+260
View File
@@ -0,0 +1,260 @@
"""Layer-1 idempotency for the OpenAI-compatible ``/v1/chat/completions`` route.
The ``/v1`` tool round-trip is fully stateless (the pause finalizes the prior
turn as ``complete`` and resumes via ``build_continuation_from_messages`` with
no ``pending_tool_state``). Dropping the native ``resume_from_tool_actions``
path also dropped its ``mark_resuming`` guard, so a duplicated/retried POST
could re-run the agent → a duplicate answer row + double token billing.
This module restores protection the OpenAI-compatible way: a client-supplied
``Idempotency-Key`` header makes retries return the *stored first response*
instead of re-running the agent. It is opt-in (no header → today's behavior,
byte-for-byte) and scoped to **non-streaming** requests only (the b2b client
and the actual regression); streaming replay is intentionally unsupported.
Storage reuses the existing ``task_dedup`` table via
:class:`~application.storage.db.repositories.idempotency.IdempotencyRepository`
— no new table or migration. The contract maps onto its claim/finalize
semantics:
- **No record** → ``claim_task`` inserts a ``pending`` row (we run + finalize).
- **``completed`` within 24h TTL** → return the cached body + status code.
- **Fresh ``pending``** (in-flight) → HTTP 409 idempotency conflict.
- **Stale ``pending``** (older than :data:`STALE_PENDING_SECONDS` — the
original request likely died) → release and re-claim.
- **``failed`` / past-TTL** → ``claim_task`` re-claims automatically.
Only successful (2xx) responses are cached; a 4xx/5xx releases the claim so a
genuine retry can still succeed (matches OpenAI).
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, Optional, Tuple
from flask import jsonify, make_response, request, Response
from sqlalchemy import text as sql_text
from application.storage.db.repositories.idempotency import IdempotencyRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
# Distinct ``task_name`` so v1 chat dedup rows never collide with ingest /
# webhook rows that share the ``task_dedup`` table.
TASK_NAME = "v1_chat_completion"
_IDEMPOTENCY_KEY_MAX_LEN = 256
# A ``pending`` claim older than this is treated as a dead in-flight request
# (the process crashed before finalize), so a genuine retry may re-claim it
# rather than waiting out the full 24h TTL or getting a permanent 409. Kept
# short enough to unblock retries quickly, long enough that a normal
# non-streaming completion (which finalizes on the same request) never trips
# it while still running.
STALE_PENDING_SECONDS = 300
def read_idempotency_key() -> Tuple[Optional[str], Optional[Response]]:
"""Read and validate the ``Idempotency-Key`` request header.
Returns:
``(key, error_response)``. An absent/empty header yields
``(None, None)`` (idempotency is opt-in). An oversized header yields
``(None, <400 response>)`` so the caller can short-circuit.
"""
key = request.headers.get("Idempotency-Key")
if not key:
return None, None
if len(key) > _IDEMPOTENCY_KEY_MAX_LEN:
return None, make_response(
jsonify(
{
"error": {
"message": (
f"Idempotency-Key exceeds maximum length of "
f"{_IDEMPOTENCY_KEY_MAX_LEN} characters"
),
"type": "invalid_request",
}
}
),
400,
)
return key, None
def scoped_key(idempotency_key: Optional[str], agent_id: Optional[str]) -> Optional[str]:
"""Compose ``{agent_id}:{idempotency_key}`` so tenants never collide.
Two agents replaying the same key value resolve to distinct stored rows.
Falls back to ``api_key`` scoping at the call site when no agent id is
available; returns ``None`` when either component is missing (idempotency
is then skipped, preserving today's behavior).
"""
if not idempotency_key or not agent_id:
return None
return f"{agent_id}:{idempotency_key}"
def _release_stale_pending(key: str) -> None:
"""Delete a stale ``pending`` claim so the caller can re-claim it.
Scoped to ``status = 'pending'`` and the staleness window so we never
clobber a live in-flight claim or a ``completed`` cache row.
"""
try:
with db_session() as conn:
conn.execute(
sql_text(
"DELETE FROM task_dedup "
"WHERE idempotency_key = :k "
"AND status = 'pending' "
"AND created_at <= now() - make_interval(secs => :secs)"
),
{"k": key, "secs": STALE_PENDING_SECONDS},
)
except Exception:
logger.exception("Failed to release stale v1 idempotency claim for key=%s", key)
def claim_or_replay(key: str) -> Tuple[bool, Optional[Response]]:
"""Claim ``key`` for this request, or return the prior outcome.
Claim-before-process: atomically insert a ``pending`` row. The three
outcomes map onto the existing ``task_dedup`` contract:
- **claimed** → ``(True, None)``: this caller runs the agent and must call
:func:`finalize` (success) or :func:`release` (error) afterwards.
- **``completed`` within TTL** → ``(False, <cached response>)``: replay the
stored body + status code without re-running.
- **fresh ``pending``** → ``(False, <409 response>)``: a same-key request is
already in progress.
A ``pending`` row older than :data:`STALE_PENDING_SECONDS` is released and
re-claimed (the original request likely died). ``failed`` / past-TTL rows
are re-claimed by ``claim_task`` itself.
Args:
key: The tenant-scoped idempotency key.
Returns:
``(claimed, response)``. When ``claimed`` is True the caller owns the
run; otherwise ``response`` is the replay/409 to return immediately.
"""
predetermined_id = str(uuid.uuid4())
with db_session() as conn:
claimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if claimed is not None:
return True, None
# Lost the claim — resolve why against the within-TTL row.
with db_readonly() as conn:
existing = IdempotencyRepository(conn).get_task(key)
if existing is not None and existing.get("status") == "completed":
return False, _replay_response(existing.get("result_json"))
if existing is not None and existing.get("status") == "pending":
# In-flight? Re-claim only if the prior claim is stale (dead request).
_release_stale_pending(key)
with db_session() as conn:
reclaimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if reclaimed is not None:
return True, None
return False, _conflict_response()
# Row vanished between claim and read (TTL cleanup / release race) — one
# more claim attempt; treat a persistent loss as a conflict.
with db_session() as conn:
reclaimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if reclaimed is not None:
return True, None
with db_readonly() as conn:
existing = IdempotencyRepository(conn).get_task(key)
if existing is not None and existing.get("status") == "completed":
return False, _replay_response(existing.get("result_json"))
return False, _conflict_response()
def finalize(key: str, response: Response) -> None:
"""Cache a successful (2xx) response under ``key``; release otherwise.
Stores ``{"status_code", "body"}`` in ``task_dedup.result_json`` so a
retry replays byte-for-byte. Non-2xx responses are not cached — the claim
is released so a genuine retry can still succeed (matches OpenAI).
Args:
key: The tenant-scoped idempotency key claimed by :func:`claim_or_replay`.
response: The Flask response produced by running the request.
"""
status_code = response.status_code
if not (200 <= status_code < 300):
release(key)
return
try:
body = response.get_json(silent=True)
except Exception:
body = None
result_json = {"status_code": status_code, "body": body}
try:
with db_session() as conn:
IdempotencyRepository(conn).finalize_task(
key=key, result_json=result_json, status="completed",
)
except Exception:
logger.exception("Failed to finalize v1 idempotency record for key=%s", key)
def release(key: str) -> None:
"""Drop this request's ``pending`` claim so a retry can re-claim it.
Used on the error path (non-2xx or an exception before finalize) so a
failed first attempt never blocks a legitimate retry for the full TTL.
"""
try:
with db_session() as conn:
conn.execute(
sql_text(
"DELETE FROM task_dedup "
"WHERE idempotency_key = :k AND status = 'pending'"
),
{"k": key},
)
except Exception:
logger.exception("Failed to release v1 idempotency claim for key=%s", key)
def _replay_response(result_json: Optional[Dict[str, Any]]) -> Response:
"""Rebuild a Flask response from a cached ``result_json`` row."""
status_code = 200
body: Any = None
if isinstance(result_json, dict):
status_code = int(result_json.get("status_code", 200))
body = result_json.get("body")
return make_response(jsonify(body), status_code)
def _conflict_response() -> Response:
"""OpenAI-shaped 409 for a same-key request already in progress."""
return make_response(
jsonify(
{
"error": {
"message": (
"A request with this Idempotency-Key is already in progress"
),
"type": "idempotency_conflict",
}
}
),
409,
)
+458
View File
@@ -0,0 +1,458 @@
"""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,
)
+623
View File
@@ -0,0 +1,623 @@
"""Translate between standard chat completions format and DocsGPT internals.
This module handles:
- Request translation (chat completions -> DocsGPT internal format)
- Response translation (DocsGPT response -> chat completions format)
- Streaming event translation (DocsGPT SSE -> standard SSE chunks)
"""
import json
import re
import time
from typing import Any, Dict, List, Optional
# Some upstream models/proxies echo their reasoning into ``content`` as
# stringified ``{'type': 'thought', 'thought': '...'}`` event reprs (instead of
# using the separate reasoning channel) — most visibly when ``response_format``
# is set. OpenAI's API never puts reasoning in ``content``, so for the
# OpenAI-compatible endpoint we strip these and reroute them to
# ``reasoning_content`` to keep ``content`` clean and compatible.
# The thought value is a Python string repr: single-quoted, or double-quoted when
# the token contains an apostrophe (e.g. "'ll"). Match the full quoted value
# (honoring escapes) so tokens containing ``}`` or newlines don't truncate the
# match and leave stray ``'}`` tails in the content.
_LEAKED_THOUGHT_RE = re.compile(
r"""\{'type': 'thought', 'thought': ('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\}""",
re.DOTALL,
)
def _strip_repr_quotes(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]:
return value[1:-1]
return value
def _split_leaked_reasoning(content: Optional[str]) -> tuple:
"""Return ``(clean_content, leaked_reasoning)``.
``clean_content`` has any stringified thought-event reprs removed;
``leaked_reasoning`` is the concatenated reasoning text that was extracted.
A no-op (returns the input unchanged) when no leak markers are present.
"""
if not content or "'type': 'thought'" not in content:
return content, ""
extracted: List[str] = []
cleaned = _LEAKED_THOUGHT_RE.sub(
lambda m: (extracted.append(_strip_repr_quotes(m.group(1))) or ""), content
)
return cleaned, "".join(extracted)
def _get_client_tool_name(tc: Dict) -> str:
"""Return the original tool name for client-facing responses.
For client-side tools the ``tool_name`` field carries the name the
client originally registered. Fall back to ``action_name`` (which
is now the clean LLM-visible name) or ``name``.
"""
return tc.get("tool_name", tc.get("action_name", tc.get("name", "")))
# ---------------------------------------------------------------------------
# Request translation
# ---------------------------------------------------------------------------
def is_continuation(messages: List[Dict]) -> bool:
"""Check if messages represent a tool-call continuation.
A continuation is detected when the last message(s) have ``role: "tool"``
immediately after an assistant message with ``tool_calls``.
"""
if not messages:
return False
# Walk backwards: if we see tool messages before hitting a non-tool, non-assistant message
# and there's an assistant message with tool_calls, it's a continuation.
i = len(messages) - 1
while i >= 0 and messages[i].get("role") == "tool":
i -= 1
if i < 0:
return False
return (
messages[i].get("role") == "assistant"
and bool(messages[i].get("tool_calls"))
)
def extract_tool_results(messages: List[Dict]) -> List[Dict]:
"""Extract tool results from trailing tool messages for continuation.
Returns a list of ``tool_actions`` dicts with ``call_id`` and ``result``.
"""
results = []
for msg in reversed(messages):
if msg.get("role") != "tool":
break
call_id = msg.get("tool_call_id", "")
content = msg.get("content", "")
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
pass
results.append({"call_id": call_id, "result": content})
results.reverse()
return results
def extract_conversation_id(messages: List[Dict]) -> Optional[str]:
"""Try to extract conversation_id from the assistant message before tool results.
The conversation_id may be stored in a custom field on the assistant message
from a previous response cycle.
"""
for msg in reversed(messages):
if msg.get("role") == "assistant":
# Check docsgpt extension
return msg.get("docsgpt", {}).get("conversation_id")
return None
def content_to_text(content: Any) -> str:
"""Flatten an OpenAI message ``content`` to plain text.
``content`` may be a string or a list of typed parts
(``{"type":"text",...}`` / ``{"type":"image_url",...}`` / ...). Only text
parts contribute; image/other parts are dropped here. The full content
array is preserved separately (see ``multimodal_content``) so images still
reach the model in the final user message.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
out = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
out.append(part.get("text", "") or "")
elif isinstance(part, str):
out.append(part)
return "\n".join(out)
return "" if content is None else str(content)
def extract_system_prompt(messages: List[Dict]) -> Optional[str]:
"""Extract the first system message content from the messages array.
Returns None if no system message is present.
"""
for msg in messages:
if msg.get("role") == "system":
return content_to_text(msg.get("content", ""))
return None
def convert_history(messages: List[Dict]) -> List[Dict]:
"""Convert chat completions messages array to DocsGPT history format.
DocsGPT history is a list of ``{prompt, response}`` dicts.
Excludes the last user message (that becomes the ``question``).
"""
history = []
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "system":
i += 1
continue
if msg.get("role") == "user":
# Look ahead for assistant response
if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant":
content = content_to_text(messages[i + 1].get("content") or "")
history.append({
"prompt": content_to_text(msg.get("content", "")),
"response": content,
})
i += 2
continue
# Last user message without response — skip (it's the question)
i += 1
continue
i += 1
return history
def extract_response_schema(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Extract a JSON schema for structured output from a chat-completions request.
Supports two request shapes:
- OpenAI ``response_format``:
``{"type": "json_schema", "json_schema": {"name": ..., "schema": {...}}}``
(a bare schema under ``json_schema`` is also tolerated).
- ``response_schema`` convenience field: a raw JSON Schema object, or a
``{"schema": {...}}`` wrapper.
Returns a raw JSON Schema object, or None. ``response_format``
``{"type": "json_object"}`` carries no schema to enforce and yields None
(the model is still steered by the system prompt).
"""
response_schema = data.get("response_schema")
if isinstance(response_schema, dict) and response_schema:
inner = response_schema.get("schema")
return inner if isinstance(inner, dict) else response_schema
response_format = data.get("response_format")
if isinstance(response_format, dict) and response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema")
if isinstance(json_schema, dict):
schema = json_schema.get("schema")
if isinstance(schema, dict):
return schema
if "type" in json_schema:
return json_schema
return None
def translate_request(
data: Dict[str, Any], api_key: str
) -> Dict[str, Any]:
"""Translate a chat completions request to DocsGPT internal format.
Args:
data: The incoming request body.
api_key: Agent API key from the Authorization header.
Returns:
Dict suitable for passing to ``StreamProcessor``.
"""
messages = data.get("messages", [])
response_schema = extract_response_schema(data)
_rf = data.get("response_format")
_rf = _rf if isinstance(_rf, dict) else {}
# OpenAI Structured Outputs default to strict; honor an explicit strict:false.
json_schema_strict = bool((_rf.get("json_schema") or {}).get("strict", True))
json_object_mode = _rf.get("type") == "json_object"
# OpenAI sampling params, forwarded to the LLM gen call (the agent otherwise
# uses its configured defaults).
sampling_params = {}
for _k in (
"temperature", "max_tokens", "max_completion_tokens",
"top_p", "frequency_penalty", "presence_penalty", "stop", "seed",
):
if data.get(_k) is not None:
sampling_params[_k] = data[_k]
# OpenAI rejects sending both; the provider maps max_tokens ->
# max_completion_tokens, so drop the alias when the canonical key is present.
if "max_completion_tokens" in sampling_params:
sampling_params.pop("max_tokens", None)
# Check for continuation (tool results after assistant tool_calls)
if is_continuation(messages):
tool_actions = extract_tool_results(messages)
conversation_id = extract_conversation_id(messages)
if not conversation_id:
conversation_id = data.get("conversation_id")
result = {
"conversation_id": conversation_id,
"tool_actions": tool_actions,
"api_key": api_key,
# Full messages array for STATELESS continuation: OpenAI clients
# (opencode, etc.) don't carry a conversation_id, so the agent is
# rebuilt from the resent messages instead of server-side state.
"messages": messages,
}
# Persistence: stateful continuations (carrying a conversation_id)
# persist the final turn; stateless ones (no conversation_id, e.g.
# opencode) skip it, else every tool round writes an orphan conversation
# with an empty question. ``docsgpt.persist`` overrides. Visibility is
# not request-controllable on v1 — rows always persist hidden, so the
# legacy ``docsgpt.save_conversation`` flag is ignored.
docsgpt_ext = data.get("docsgpt", {})
result["persist"] = bool(docsgpt_ext.get("persist", bool(conversation_id)))
# Carry tools forward for next iteration
if data.get("tools"):
result["client_tools"] = data["tools"]
if response_schema is not None:
result["json_schema"] = response_schema
result["json_schema_strict"] = json_schema_strict
if json_object_mode:
result["json_object"] = True
if sampling_params:
result["llm_params"] = sampling_params
return result
# Normal request — extract the question (text) from the last user message,
# and keep its full content array (text + image_url parts) when multimodal so
# images still reach the model in the final user message.
last_user_content = None
for msg in reversed(messages):
if msg.get("role") == "user":
last_user_content = msg.get("content")
break
question = content_to_text(last_user_content)
multimodal_content = last_user_content if isinstance(last_user_content, list) else None
history = convert_history(messages)
system_prompt_override = extract_system_prompt(messages)
docsgpt = data.get("docsgpt", {})
result = {
"question": question,
"api_key": api_key,
"history": json.dumps(history),
# v1 conversations always persist and stay hidden from the agent
# owner's sidebar; the legacy ``docsgpt.save_conversation`` flag
# (old meaning: "persist this conversation") is ignored.
}
if system_prompt_override is not None:
result["system_prompt_override"] = system_prompt_override
# Client tools
if data.get("tools"):
result["client_tools"] = data["tools"]
# DocsGPT extensions
if docsgpt.get("attachments"):
result["attachments"] = docsgpt["attachments"]
if response_schema is not None:
result["json_schema"] = response_schema
result["json_schema_strict"] = json_schema_strict
if json_object_mode:
result["json_object"] = True
if sampling_params:
result["llm_params"] = sampling_params
if multimodal_content is not None:
result["multimodal_content"] = multimodal_content
return result
# ---------------------------------------------------------------------------
# Response translation (non-streaming)
# ---------------------------------------------------------------------------
def translate_response(
conversation_id: str,
answer: str,
sources: Optional[List[Dict]],
tool_calls: Optional[List[Dict]],
thought: str,
model_name: str,
pending_tool_calls: Optional[List[Dict]] = None,
strip_reasoning_leak: bool = False,
) -> Dict[str, Any]:
"""Translate DocsGPT response to chat completions format.
Args:
conversation_id: The DocsGPT conversation ID.
answer: The assistant's text response.
sources: RAG retrieval sources.
tool_calls: Completed tool call results.
thought: Reasoning/thinking tokens.
model_name: Model/agent identifier.
pending_tool_calls: Pending client-side tool calls (if paused).
Returns:
Dict in the standard chat completions response format.
"""
created = int(time.time())
completion_id = f"chatcmpl-{conversation_id}" if conversation_id else f"chatcmpl-{created}"
# Build message
message: Dict[str, Any] = {"role": "assistant"}
if pending_tool_calls:
# Tool calls pending — return them for client execution
message["content"] = None
message["tool_calls"] = [
{
"id": tc.get("call_id", ""),
"type": "function",
"function": {
"name": _get_client_tool_name(tc),
"arguments": (
json.dumps(tc["arguments"])
if isinstance(tc.get("arguments"), dict)
else tc.get("arguments", "{}")
),
},
}
for tc in pending_tool_calls
]
finish_reason = "tool_calls"
else:
if strip_reasoning_leak:
clean_answer, leaked_reasoning = _split_leaked_reasoning(answer)
else:
clean_answer, leaked_reasoning = answer, ""
message["content"] = clean_answer
combined_reasoning = (thought or "") + leaked_reasoning
if combined_reasoning:
message["reasoning_content"] = combined_reasoning
finish_reason = "stop"
result: Dict[str, Any] = {
"id": completion_id,
"object": "chat.completion",
"created": created,
"model": model_name,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
}
# DocsGPT extensions
docsgpt: Dict[str, Any] = {}
if conversation_id:
docsgpt["conversation_id"] = conversation_id
if sources:
docsgpt["sources"] = sources
if tool_calls:
docsgpt["tool_calls"] = tool_calls
if docsgpt:
result["docsgpt"] = docsgpt
return result
# ---------------------------------------------------------------------------
# Streaming event translation
# ---------------------------------------------------------------------------
def _make_chunk(
completion_id: str,
model_name: str,
delta: Dict[str, Any],
finish_reason: Optional[str] = None,
) -> str:
"""Build a single SSE chunk in the standard streaming format."""
chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model_name,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
return f"data: {json.dumps(chunk)}\n\n"
def _make_docsgpt_chunk(data: Dict[str, Any], completion_id: str, model_name: str) -> str:
"""Build a DocsGPT extension chunk that is ALSO a valid ``chat.completion.chunk``.
Strict OpenAI clients (e.g. the Vercel AI SDK used by opencode) validate every
SSE ``data:`` frame as a chat.completion.chunk, so the DocsGPT extension is
attached to an otherwise-empty (no-op) chunk rather than sent as a bare
``{"docsgpt": ...}`` object — which has no ``choices`` and fails validation.
OpenAI clients ignore the extra top-level ``docsgpt`` field.
"""
chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model_name,
"choices": [{"index": 0, "delta": {}, "finish_reason": None}],
"docsgpt": data,
}
return f"data: {json.dumps(chunk)}\n\n"
def translate_stream_event(
event_data: Dict[str, Any],
completion_id: str,
model_name: str,
strip_reasoning_leak: bool = False,
) -> List[str]:
"""Translate a DocsGPT SSE event dict to standard streaming chunks.
May return 0, 1, or 2 chunks per input event. For example, a completed
tool call produces both a docsgpt extension chunk and nothing on the
standard side (since server-side tool calls aren't surfaced in standard
format).
Args:
event_data: Parsed DocsGPT event dict.
completion_id: The completion ID for this response.
model_name: Model/agent identifier.
Returns:
List of SSE-formatted strings to send to the client.
"""
event_type = event_data.get("type")
chunks: List[str] = []
if event_type == "answer":
raw = event_data.get("answer", "")
clean, leaked = (
_split_leaked_reasoning(raw) if strip_reasoning_leak else (raw, "")
)
if leaked:
chunks.append(
_make_chunk(completion_id, model_name, {"reasoning_content": leaked})
)
if clean:
chunks.append(
_make_chunk(completion_id, model_name, {"content": clean})
)
elif event_type == "thought":
chunks.append(
_make_chunk(
completion_id, model_name,
{"reasoning_content": event_data.get("thought", "")},
)
)
elif event_type == "source":
chunks.append(
_make_docsgpt_chunk(
{"type": "source", "sources": event_data.get("source", [])},
completion_id, model_name,
)
)
elif event_type == "tool_call":
tc_data = event_data.get("data", {})
status = tc_data.get("status")
if status == "requires_client_execution":
# Standard: stream as tool_calls delta
args = tc_data.get("arguments", {})
args_str = json.dumps(args) if isinstance(args, dict) else str(args)
chunks.append(
_make_chunk(completion_id, model_name, {
"tool_calls": [{
"index": 0,
"id": tc_data.get("call_id", ""),
"type": "function",
"function": {
"name": _get_client_tool_name(tc_data),
"arguments": args_str,
},
}],
})
)
elif status == "awaiting_approval":
# Extension: approval needed
chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data}, completion_id, model_name))
elif status in ("completed", "pending", "error", "denied", "skipped"):
# Extension: tool call progress
chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data}, completion_id, model_name))
elif event_type == "tool_calls_pending":
# Standard: finish_reason = tool_calls
chunks.append(
_make_chunk(completion_id, model_name, {}, finish_reason="tool_calls")
)
# Also emit as docsgpt extension
chunks.append(
_make_docsgpt_chunk(
{
"type": "tool_calls_pending",
"pending_tool_calls": event_data.get("data", {}).get("pending_tool_calls", []),
},
completion_id, model_name,
)
)
elif event_type == "end":
chunks.append(
_make_chunk(completion_id, model_name, {}, finish_reason="stop")
)
chunks.append("data: [DONE]\n\n")
elif event_type == "id":
# Skip the "None" placeholder conversation_id emitted when the call is
# not persisted (persist=false tool rounds) — nothing useful to surface.
conv_id = event_data.get("id", "")
if conv_id and conv_id != "None":
chunks.append(
_make_docsgpt_chunk(
{"type": "id", "conversation_id": conv_id},
completion_id, model_name,
)
)
elif event_type == "error":
# Emit as standard error (non-standard but widely supported)
error_data = {
"error": {
"message": event_data.get("error", "An error occurred"),
"type": "server_error",
}
}
chunks.append(f"data: {json.dumps(error_data)}\n\n")
elif event_type == "structured_answer":
raw = event_data.get("answer", "")
clean, leaked = (
_split_leaked_reasoning(raw) if strip_reasoning_leak else (raw, "")
)
if leaked:
chunks.append(
_make_chunk(completion_id, model_name, {"reasoning_content": leaked})
)
if clean:
chunks.append(
_make_chunk(completion_id, model_name, {"content": clean})
)
# Skip: tool_calls (redundant), research_plan, research_progress
return chunks