mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-16 02:15:08 +00:00
refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
This commit is contained in:
1 parent
3e7f1f91b4
commit
574f96341e
984 files changed
+9847
-9682
No files matched your search
@@ -0,0 +1,3 @@
|
||||
from docsgpt.api.v1.routes import v1_bp
|
||||
|
||||
__all__ = ["v1_bp"]
|
||||
@@ -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:`~docsgpt.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 docsgpt.storage.db.repositories.idempotency import IdempotencyRepository
|
||||
from docsgpt.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,
|
||||
)
|
||||
@@ -0,0 +1,649 @@
|
||||
"""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 docsgpt.api.answer.routes.base import BaseAnswerResource
|
||||
from docsgpt.api.answer.services.persistence_policy import resolve_persistence
|
||||
from docsgpt.api.answer.services.continuation_service import (
|
||||
ContinuationService,
|
||||
RESUME_IN_PROGRESS_MESSAGE,
|
||||
ResumeInProgressError,
|
||||
)
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
from docsgpt.api.v1 import idempotency as v1_idempotency
|
||||
from docsgpt.api.v1.session_store import (
|
||||
V1Session,
|
||||
delete_conversation,
|
||||
identify_session,
|
||||
load_conversation,
|
||||
save_conversation,
|
||||
)
|
||||
from docsgpt.api.v1.translator import (
|
||||
StreamTranslationState,
|
||||
make_usage_chunk,
|
||||
translate_request,
|
||||
translate_response,
|
||||
translate_stream_event,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.conversations import ConversationsRepository
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
from docsgpt.streaming.sse_keepalive import with_sse_keepalive
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _invalid_request(message: str, code: Optional[str] = None) -> Response:
|
||||
"""Return an OpenAI-shaped invalid-request response."""
|
||||
return make_response(
|
||||
jsonify({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "invalid_request_error",
|
||||
**({"code": code} if code else {}),
|
||||
}
|
||||
}),
|
||||
400,
|
||||
)
|
||||
|
||||
|
||||
def _validate_request_options(data: Dict[str, Any], agent: Dict[str, Any]) -> Optional[Response]:
|
||||
"""Reject unsupported options instead of silently changing semantics."""
|
||||
# The API key, not the OpenAI ``model`` placeholder, selects the agent.
|
||||
# Keep accepting arbitrary model strings as documented for compatibility
|
||||
# with clients that require a locally configured model alias.
|
||||
if data.get("n") not in (None, 1):
|
||||
return _invalid_request("DocsGPT currently supports only n=1.")
|
||||
if data.get("logprobs") not in (None, False):
|
||||
return _invalid_request("logprobs is not supported by this endpoint.")
|
||||
stream_options = data.get("stream_options")
|
||||
if stream_options is not None and not isinstance(stream_options, dict):
|
||||
return _invalid_request("stream_options must be an object.")
|
||||
return None
|
||||
|
||||
|
||||
def _conversation_belongs_to_agent(
|
||||
conversation_id: str, user_id: str, agent_id: str
|
||||
) -> bool:
|
||||
"""Return whether a conversation is accessible and bound to this agent."""
|
||||
if not conversation_id or not user_id or not agent_id:
|
||||
return False
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
conversation = ConversationsRepository(conn).get_any(
|
||||
str(conversation_id), str(user_id)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to authorize v1 conversation", exc_info=True)
|
||||
return False
|
||||
return bool(
|
||||
conversation
|
||||
and conversation.get("agent_id")
|
||||
and str(conversation["agent_id"]) == str(agent_id)
|
||||
)
|
||||
|
||||
|
||||
def _response_usage(agent: Any) -> Dict[str, Any]:
|
||||
"""Return the turn's cumulative usage in Chat Completions shape.
|
||||
|
||||
Reads the per-instance accumulator so multi-round tool turns report
|
||||
the sum of every LLM call, matching what ``token_usage`` rows bill;
|
||||
the accumulator carries provider-exact counts whenever the upstream
|
||||
reported them (see ``_prefer_provider_usage``).
|
||||
"""
|
||||
tokens = getattr(getattr(agent, "llm", None), "token_usage", {}) or {}
|
||||
prompt = int(tokens.get("prompt_tokens", 0) or 0)
|
||||
completion = int(tokens.get("generated_tokens", 0) or 0)
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
}
|
||||
|
||||
|
||||
def _response_finish_reason(agent: Any) -> str:
|
||||
"""Return the upstream terminal reason understood by Chat Completions."""
|
||||
reason = getattr(getattr(agent, "llm", None), "_last_finish_reason", None)
|
||||
return reason if reason in {"stop", "length"} else "stop"
|
||||
|
||||
|
||||
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)
|
||||
if not agent_doc:
|
||||
return make_response(
|
||||
jsonify({"error": {"message": "Invalid API key", "type": "auth_error"}}),
|
||||
401,
|
||||
)
|
||||
options_error = _validate_request_options(data, agent_doc)
|
||||
if options_error is not None:
|
||||
return options_error
|
||||
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,
|
||||
)
|
||||
|
||||
agent_id_value = str(agent_doc.get("id") or agent_doc.get("_id") or "")
|
||||
client_session = identify_session(request.headers, data, agent_id_value)
|
||||
explicit_conversation = (
|
||||
request.headers.get("X-DocsGPT-Conversation-ID")
|
||||
or internal_data.get("conversation_id")
|
||||
)
|
||||
conversation_from_session = False
|
||||
if explicit_conversation:
|
||||
internal_data["conversation_id"] = explicit_conversation
|
||||
elif not internal_data.get("conversation_id"):
|
||||
correlated_conversation = load_conversation(client_session)
|
||||
if correlated_conversation:
|
||||
internal_data["conversation_id"] = correlated_conversation
|
||||
conversation_from_session = True
|
||||
|
||||
# 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"}
|
||||
|
||||
conversation_id = internal_data.get("conversation_id")
|
||||
if conversation_id and not _conversation_belongs_to_agent(
|
||||
str(conversation_id), str(decoded_token["sub"]), agent_id_value
|
||||
):
|
||||
if conversation_from_session:
|
||||
# A deleted or reassigned conversation must not poison this client
|
||||
# session for the remainder of its Redis TTL. Start a fresh hidden
|
||||
# conversation and replace the mapping after the request succeeds.
|
||||
delete_conversation(client_session)
|
||||
internal_data.pop("conversation_id", None)
|
||||
else:
|
||||
return _invalid_request(
|
||||
"Conversation not found for the authenticated agent.",
|
||||
code="conversation_not_found",
|
||||
)
|
||||
|
||||
if internal_data.get("tool_actions") and internal_data.get("conversation_id"):
|
||||
internal_data["persist"] = True
|
||||
|
||||
try:
|
||||
processor = StreamProcessor(internal_data, decoded_token)
|
||||
|
||||
if internal_data.get("tool_actions"):
|
||||
conversation_id = internal_data.get("conversation_id")
|
||||
pending_state = (
|
||||
ContinuationService().claim_state(
|
||||
conversation_id, decoded_token["sub"]
|
||||
)
|
||||
if conversation_id
|
||||
else None
|
||||
)
|
||||
if conversation_id and pending_state:
|
||||
(
|
||||
agent,
|
||||
messages,
|
||||
tools_dict,
|
||||
pending_tool_calls,
|
||||
tool_actions,
|
||||
reasoning_content,
|
||||
) = processor.resume_from_tool_actions(
|
||||
internal_data["tool_actions"],
|
||||
conversation_id,
|
||||
claimed_state=pending_state,
|
||||
)
|
||||
processor.conversation_id = conversation_id
|
||||
else:
|
||||
# Compatibility fallback for old/completed conversations and
|
||||
# clients that resend the full transcript without resumable
|
||||
# server state. StreamProcessor still enforces conversation
|
||||
# ownership while loading history.
|
||||
(
|
||||
agent,
|
||||
messages,
|
||||
tools_dict,
|
||||
pending_tool_calls,
|
||||
tool_actions,
|
||||
reasoning_content,
|
||||
) = processor.build_continuation_from_messages(
|
||||
internal_data.get("messages", []),
|
||||
internal_data["tool_actions"],
|
||||
)
|
||||
# A missing/expired durable continuation has no reserved WAL
|
||||
# row to finalize, so nothing mid-loop is persisted — but the
|
||||
# FINAL turn of this round must still land in the mapped
|
||||
# conversation, else a loop whose first resume produces the
|
||||
# answer (one pause, then stop — the common single-search
|
||||
# shape) persists only the empty tool-call row and the answer
|
||||
# is lost upstream. With persist on, the finished answer is
|
||||
# APPENDED as a new empty-prompt turn (there is no reserved
|
||||
# row to finalize into); that's the accepted shape here. No
|
||||
# worthless rows appear along the way: continuation rounds
|
||||
# reserve no WAL row and the pause path never appends. Only a
|
||||
# round with no mapped conversation at all stays stateless.
|
||||
internal_data["persist"] = bool(internal_data.get("conversation_id"))
|
||||
continuation = {
|
||||
"messages": messages,
|
||||
"tools_dict": tools_dict,
|
||||
"pending_tool_calls": pending_tool_calls,
|
||||
"tool_actions": tool_actions,
|
||||
"reasoning_content": reasoning_content,
|
||||
# Stateful compatibility resumes must finalize the original
|
||||
# WAL placeholder and keep its request attribution. Omitting
|
||||
# these made OpenCode rounds append an orphan response while
|
||||
# leaving the initial message permanently ``streaming``.
|
||||
"reserved_message_id": processor.reserved_message_id,
|
||||
"request_id": processor.request_id,
|
||||
}
|
||||
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")
|
||||
)
|
||||
finalize_stateless_tool_pause = bool(
|
||||
client_session is None and not internal_data.get("conversation_id")
|
||||
)
|
||||
|
||||
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(
|
||||
with_sse_keepalive(
|
||||
_stream_response(
|
||||
helper,
|
||||
question,
|
||||
agent,
|
||||
processor,
|
||||
model_name,
|
||||
continuation,
|
||||
should_persist,
|
||||
visibility,
|
||||
strip_reasoning_leak,
|
||||
bool((data.get("stream_options") or {}).get("include_usage")),
|
||||
client_session,
|
||||
finalize_stateless_tool_pause,
|
||||
),
|
||||
),
|
||||
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,
|
||||
client_session,
|
||||
finalize_stateless_tool_pause,
|
||||
)
|
||||
|
||||
# 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 ResumeInProgressError:
|
||||
if idem_key:
|
||||
v1_idempotency.release(idem_key)
|
||||
return make_response(
|
||||
jsonify({
|
||||
"error": {
|
||||
"message": RESUME_IN_PROGRESS_MESSAGE,
|
||||
"type": "conflict_error",
|
||||
"code": "resume_in_progress",
|
||||
}
|
||||
}),
|
||||
409,
|
||||
)
|
||||
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,
|
||||
include_usage: bool = False,
|
||||
client_session: Optional[V1Session] = None,
|
||||
finalize_stateless_tool_pause: 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,
|
||||
finalize_tool_pause_as_complete=finalize_stateless_tool_pause,
|
||||
)
|
||||
|
||||
translation_state = StreamTranslationState()
|
||||
|
||||
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}"
|
||||
save_conversation(client_session, conv_id)
|
||||
|
||||
# Translate to standard format
|
||||
if event_data.get("type") == "end":
|
||||
event_data["finish_reason"] = _response_finish_reason(agent)
|
||||
if event_data.get("type") == "end" and include_usage:
|
||||
yield make_usage_chunk(completion_id, model_name, _response_usage(agent))
|
||||
translated = translate_stream_event(
|
||||
event_data,
|
||||
completion_id,
|
||||
model_name,
|
||||
strip_reasoning_leak,
|
||||
translation_state,
|
||||
)
|
||||
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,
|
||||
client_session: Optional[V1Session] = None,
|
||||
finalize_stateless_tool_pause: 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,
|
||||
finalize_tool_pause_as_complete=finalize_stateless_tool_pause,
|
||||
)
|
||||
|
||||
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
|
||||
save_conversation(client_session, result.get("conversation_id"))
|
||||
|
||||
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,
|
||||
usage=_response_usage(agent),
|
||||
finish_reason_override=_response_finish_reason(agent),
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Short-lived correlation for chat-completions clients.
|
||||
|
||||
OpenAI's chat-completions schema has no conversation id. Coding clients such
|
||||
as OpenCode do send stable session headers, though, so map those opaque values
|
||||
to DocsGPT conversations without storing or logging the raw client id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V1Session:
|
||||
"""A hashed Redis key for one agent/client/prompt namespace."""
|
||||
|
||||
key: str
|
||||
|
||||
|
||||
def _instruction_fingerprint(messages: list[dict[str, Any]]) -> str:
|
||||
instructions = [
|
||||
{"role": item.get("role"), "content": item.get("content")}
|
||||
for item in messages
|
||||
if item.get("role") in ("system", "developer")
|
||||
]
|
||||
encoded = json.dumps(instructions, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def identify_session(
|
||||
headers: Mapping[str, str], data: dict[str, Any], agent_id: str
|
||||
) -> Optional[V1Session]:
|
||||
"""Build a privacy-preserving correlation key when the client supplies one."""
|
||||
raw = (
|
||||
headers.get("X-DocsGPT-Session-ID")
|
||||
or (data.get("docsgpt") or {}).get("session_id")
|
||||
or headers.get("X-Session-ID")
|
||||
or headers.get("X-Session-Id")
|
||||
or headers.get("X-Session-Affinity")
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
prompt = _instruction_fingerprint(data.get("messages") or [])
|
||||
digest = hashlib.sha256(f"{agent_id}\0{raw}\0{prompt}".encode("utf-8")).hexdigest()
|
||||
return V1Session(key=f"v1:session:{digest}")
|
||||
|
||||
|
||||
def load_conversation(session: Optional[V1Session]) -> Optional[str]:
|
||||
"""Resolve a session key, degrading cleanly when Redis is unavailable."""
|
||||
if session is None:
|
||||
return None
|
||||
client = get_redis_instance()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
value = client.get(session.key)
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
except Exception:
|
||||
logger.warning("Unable to load v1 session correlation", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def save_conversation(session: Optional[V1Session], conversation_id: Optional[str]) -> None:
|
||||
"""Store a session mapping with a bounded lifetime."""
|
||||
if session is None or not conversation_id or conversation_id == "None":
|
||||
return
|
||||
client = get_redis_instance()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.set(
|
||||
session.key,
|
||||
str(conversation_id),
|
||||
ex=settings.V1_SESSION_TTL_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Unable to save v1 session correlation", exc_info=True)
|
||||
|
||||
|
||||
def delete_conversation(session: Optional[V1Session]) -> None:
|
||||
"""Delete a stale session mapping, degrading cleanly without Redis."""
|
||||
if session is None:
|
||||
return
|
||||
client = get_redis_instance()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.delete(session.key)
|
||||
except Exception:
|
||||
logger.warning("Unable to delete v1 session correlation", exc_info=True)
|
||||
@@ -0,0 +1,680 @@
|
||||
"""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 dataclasses import dataclass, field
|
||||
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]:
|
||||
"""Combine system and developer instructions in request order.
|
||||
|
||||
Chat-completions clients use both roles. DocsGPT has one prompt-override
|
||||
slot, so preserving their order is the least surprising translation.
|
||||
Returns None when neither role is present.
|
||||
"""
|
||||
prompts = [
|
||||
content_to_text(msg.get("content", ""))
|
||||
for msg in messages
|
||||
if msg.get("role") in ("system", "developer")
|
||||
]
|
||||
return "\n\n".join(prompts) if prompts else 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") in ("system", "developer"):
|
||||
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)
|
||||
if data.get("tools") and data.get("tool_choice") is not None:
|
||||
sampling_params["tool_choice"] = data["tool_choice"]
|
||||
if data.get("tools") and data.get("parallel_tool_calls") is not None:
|
||||
sampling_params["parallel_tool_calls"] = data["parallel_tool_calls"]
|
||||
|
||||
# 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") or (
|
||||
data.get("docsgpt") or {}
|
||||
).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,
|
||||
}
|
||||
if data.get("conversation_id") and not result.get("conversation_id"):
|
||||
result["conversation_id"] = data["conversation_id"]
|
||||
# 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.
|
||||
}
|
||||
conversation_id = data.get("conversation_id") or docsgpt.get("conversation_id")
|
||||
if conversation_id:
|
||||
result["conversation_id"] = conversation_id
|
||||
|
||||
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,
|
||||
usage: Optional[Dict[str, Any]] = None,
|
||||
finish_reason_override: Optional[str] = None,
|
||||
) -> 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 = finish_reason_override or "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": usage or {
|
||||
"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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamTranslationState:
|
||||
"""State shared while translating one internal stream."""
|
||||
|
||||
tool_indices: Dict[str, int] = field(default_factory=dict)
|
||||
tool_pause_finished: bool = False
|
||||
done: bool = False
|
||||
|
||||
|
||||
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_usage_chunk(
|
||||
completion_id: str, model_name: str, usage: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Build the optional final usage chunk used by ``stream_options``."""
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model_name,
|
||||
"choices": [],
|
||||
"usage": usage,
|
||||
}
|
||||
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,
|
||||
state: Optional[StreamTranslationState] = None,
|
||||
) -> 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] = []
|
||||
state = state or StreamTranslationState()
|
||||
|
||||
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)
|
||||
call_id = tc_data.get("call_id", "")
|
||||
if call_id not in state.tool_indices:
|
||||
state.tool_indices[call_id] = len(state.tool_indices)
|
||||
chunks.append(
|
||||
_make_chunk(completion_id, model_name, {
|
||||
"tool_calls": [{
|
||||
"index": state.tool_indices[call_id],
|
||||
"id": 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
|
||||
if not state.tool_pause_finished:
|
||||
chunks.append(
|
||||
_make_chunk(completion_id, model_name, {}, finish_reason="tool_calls")
|
||||
)
|
||||
state.tool_pause_finished = True
|
||||
# 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":
|
||||
if not state.done:
|
||||
if not state.tool_pause_finished:
|
||||
chunks.append(
|
||||
_make_chunk(
|
||||
completion_id,
|
||||
model_name,
|
||||
{},
|
||||
finish_reason=event_data.get("finish_reason") or "stop",
|
||||
)
|
||||
)
|
||||
chunks.append("data: [DONE]\n\n")
|
||||
state.done = True
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user