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:
Alex committed 2026-09-07 10:20:43 +01:00
1 parent 3e7f1f91b4
commit 574f96341e
984 files changed
+9847 -9682

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
from docsgpt.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:`~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,
)
+649
View File
@@ -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,
)
+99
View File
@@ -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)
+680
View File
@@ -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