mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 22:13:08 +00:00
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.
1197 lines
43 KiB
Python
1197 lines
43 KiB
Python
"""Tests for the v1 API translator.
|
|
|
|
Covers request translation, response translation, streaming event
|
|
translation, continuation detection, and history conversion.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from docsgpt.api.v1.translator import (
|
|
StreamTranslationState,
|
|
_get_client_tool_name,
|
|
_split_leaked_reasoning,
|
|
_strip_repr_quotes,
|
|
content_to_text,
|
|
convert_history,
|
|
extract_response_schema,
|
|
extract_system_prompt,
|
|
extract_tool_results,
|
|
is_continuation,
|
|
translate_request,
|
|
translate_response,
|
|
translate_stream_event,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_continuation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIsContinuation:
|
|
|
|
def test_normal_messages_not_continuation(self):
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert is_continuation(messages) is False
|
|
|
|
def test_tool_after_assistant_tool_calls_is_continuation(self):
|
|
messages = [
|
|
{"role": "user", "content": "What's the weather?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": '{"temp": "72F"}'},
|
|
]
|
|
assert is_continuation(messages) is True
|
|
|
|
def test_assistant_without_tool_calls_not_continuation(self):
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi"},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "result"},
|
|
]
|
|
# assistant has no tool_calls — not a valid continuation
|
|
assert is_continuation(messages) is False
|
|
|
|
def test_empty_messages(self):
|
|
assert is_continuation([]) is False
|
|
|
|
def test_multiple_tool_results(self):
|
|
messages = [
|
|
{"role": "user", "content": "Do stuff"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{"id": "c1", "type": "function", "function": {"name": "a", "arguments": "{}"}},
|
|
{"id": "c2", "type": "function", "function": {"name": "b", "arguments": "{}"}},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
|
|
{"role": "tool", "tool_call_id": "c2", "content": "r2"},
|
|
]
|
|
assert is_continuation(messages) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_tool_results
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractToolResults:
|
|
|
|
def test_extracts_results(self):
|
|
messages = [
|
|
{"role": "assistant", "tool_calls": [{"id": "c1"}]},
|
|
{"role": "tool", "tool_call_id": "c1", "content": '{"temp": "72F"}'},
|
|
]
|
|
results = extract_tool_results(messages)
|
|
assert len(results) == 1
|
|
assert results[0]["call_id"] == "c1"
|
|
assert results[0]["result"] == {"temp": "72F"}
|
|
|
|
def test_string_content(self):
|
|
messages = [
|
|
{"role": "tool", "tool_call_id": "c1", "content": "plain text"},
|
|
]
|
|
results = extract_tool_results(messages)
|
|
assert results[0]["result"] == "plain text"
|
|
|
|
def test_multiple_results(self):
|
|
messages = [
|
|
{"role": "assistant", "tool_calls": []},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
|
|
{"role": "tool", "tool_call_id": "c2", "content": "r2"},
|
|
]
|
|
results = extract_tool_results(messages)
|
|
assert len(results) == 2
|
|
assert results[0]["call_id"] == "c1"
|
|
assert results[1]["call_id"] == "c2"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_history
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestConvertHistory:
|
|
|
|
def test_user_assistant_pairs(self):
|
|
messages = [
|
|
{"role": "system", "content": "You are helpful"},
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there"},
|
|
{"role": "user", "content": "How are you?"},
|
|
{"role": "assistant", "content": "I'm good"},
|
|
{"role": "user", "content": "What's 2+2?"}, # Last user = question
|
|
]
|
|
history = convert_history(messages)
|
|
assert len(history) == 2
|
|
assert history[0]["prompt"] == "Hello"
|
|
assert history[0]["response"] == "Hi there"
|
|
assert history[1]["prompt"] == "How are you?"
|
|
assert history[1]["response"] == "I'm good"
|
|
|
|
def test_single_user_message(self):
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
history = convert_history(messages)
|
|
assert history == []
|
|
|
|
def test_system_messages_skipped(self):
|
|
messages = [
|
|
{"role": "system", "content": "System prompt"},
|
|
{"role": "user", "content": "Question"},
|
|
]
|
|
history = convert_history(messages)
|
|
assert history == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_system_prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractSystemPrompt:
|
|
|
|
def test_extracts_first_system_message(self):
|
|
messages = [
|
|
{"role": "system", "content": "You are a pirate"},
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert extract_system_prompt(messages) == "You are a pirate"
|
|
|
|
def test_returns_none_when_no_system_message(self):
|
|
messages = [{"role": "user", "content": "Hello"}]
|
|
assert extract_system_prompt(messages) is None
|
|
|
|
def test_combines_multiple_instruction_messages_in_order(self):
|
|
messages = [
|
|
{"role": "system", "content": "First"},
|
|
{"role": "developer", "content": "Second"},
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert extract_system_prompt(messages) == "First\n\nSecond"
|
|
|
|
def test_empty_content_returns_empty_string(self):
|
|
messages = [
|
|
{"role": "system", "content": ""},
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert extract_system_prompt(messages) == ""
|
|
|
|
def test_missing_content_returns_empty_string(self):
|
|
messages = [
|
|
{"role": "system"},
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert extract_system_prompt(messages) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_request
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTranslateRequest:
|
|
|
|
def test_normal_request(self):
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi"},
|
|
{"role": "user", "content": "What's 2+2?"},
|
|
],
|
|
}
|
|
result = translate_request(data, "test-key")
|
|
assert result["question"] == "What's 2+2?"
|
|
assert result["api_key"] == "test-key"
|
|
# v1 conversations persist but never list in the agent owner's
|
|
# sidebar; the translator no longer emits a display flag at all.
|
|
assert "save_conversation" not in result
|
|
history = json.loads(result["history"])
|
|
assert len(history) == 1
|
|
assert history[0]["prompt"] == "Hello"
|
|
|
|
def test_legacy_save_conversation_flag_is_ignored(self):
|
|
# Pre-visibility clients send ``docsgpt.save_conversation: true``
|
|
# meaning "persist" (the old contract). It must not leak into the
|
|
# internal request — visibility is not request-controllable on v1.
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"docsgpt": {"save_conversation": True},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert "save_conversation" not in result
|
|
|
|
def test_continuation_request(self):
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "Search for X"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": '{"found": true}'},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert "tool_actions" in result
|
|
assert len(result["tool_actions"]) == 1
|
|
assert result["tool_actions"][0]["call_id"] == "c1"
|
|
|
|
def test_stateful_continuation_persists_by_default(self):
|
|
"""A stateful continuation (conversation_id present) implies the first
|
|
turn was saved, so the resumed turn must persist too (otherwise the
|
|
final answer + WAL row are lost)."""
|
|
data = {
|
|
"conversation_id": "conv-1",
|
|
"messages": [
|
|
{"role": "user", "content": "Search for X"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["persist"] is True
|
|
|
|
def test_stateless_continuation_does_not_persist_by_default(self):
|
|
"""A stateless continuation (no conversation_id, e.g. an OpenAI client
|
|
such as opencode) never persisted turn 1, so it must NOT default to
|
|
saving -- otherwise every tool round leaks an orphan conversation."""
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "Search for X"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["persist"] is False
|
|
|
|
def test_stateful_continuation_persist_override_via_docsgpt(self):
|
|
"""``docsgpt.persist=false`` forces no persistence even with a
|
|
conversation_id."""
|
|
data = {
|
|
"conversation_id": "conv-1",
|
|
"docsgpt": {"persist": False},
|
|
"messages": [
|
|
{"role": "user", "content": "Search for X"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["persist"] is False
|
|
|
|
def test_continuation_ignores_legacy_save_conversation_flag(self):
|
|
"""``docsgpt.save_conversation`` is a dead flag — persist is separate."""
|
|
data = {
|
|
"docsgpt": {"save_conversation": True},
|
|
"messages": [
|
|
{"role": "user", "content": "Search for X"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert "save_conversation" not in result
|
|
# Stateless continuation (no conversation_id) still skips persistence.
|
|
assert result["persist"] is False
|
|
|
|
def test_continuation_with_top_level_conversation_id(self):
|
|
"""Standard clients send conversation_id at request level, not in messages."""
|
|
data = {
|
|
"conversation_id": "conv-top-level",
|
|
"messages": [
|
|
{"role": "user", "content": "Do stuff"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "act", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["conversation_id"] == "conv-top-level"
|
|
|
|
def test_continuation_in_message_conversation_id_takes_precedence(self):
|
|
"""When both in-message and top-level conversation_id exist, in-message wins."""
|
|
data = {
|
|
"conversation_id": "conv-top-level",
|
|
"messages": [
|
|
{"role": "user", "content": "Do stuff"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "act", "arguments": "{}"}}],
|
|
"docsgpt": {"conversation_id": "conv-in-message"},
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["conversation_id"] == "conv-in-message"
|
|
|
|
def test_client_tools_passed_through(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"tools": [{"type": "function", "function": {"name": "my_tool"}}],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["client_tools"] == data["tools"]
|
|
|
|
def test_docsgpt_attachments(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"docsgpt": {"attachments": ["att1", "att2"]},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["attachments"] == ["att1", "att2"]
|
|
|
|
def test_system_prompt_override_included_when_present(self):
|
|
data = {
|
|
"messages": [
|
|
{"role": "system", "content": "Custom prompt"},
|
|
{"role": "user", "content": "Hello"},
|
|
],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["system_prompt_override"] == "Custom prompt"
|
|
|
|
def test_system_prompt_override_absent_when_no_system_message(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert "system_prompt_override" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTranslateResponse:
|
|
|
|
def test_basic_response(self):
|
|
resp = translate_response(
|
|
conversation_id="conv-1",
|
|
answer="Hello!",
|
|
sources=[],
|
|
tool_calls=[],
|
|
thought="",
|
|
model_name="my-agent",
|
|
)
|
|
assert resp["id"] == "chatcmpl-conv-1"
|
|
assert resp["object"] == "chat.completion"
|
|
assert resp["model"] == "my-agent"
|
|
assert resp["choices"][0]["message"]["content"] == "Hello!"
|
|
assert resp["choices"][0]["finish_reason"] == "stop"
|
|
assert "reasoning_content" not in resp["choices"][0]["message"]
|
|
|
|
def test_response_with_thought(self):
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="Result",
|
|
sources=[],
|
|
tool_calls=[],
|
|
thought="Thinking about it...",
|
|
model_name="agent",
|
|
)
|
|
assert resp["choices"][0]["message"]["reasoning_content"] == "Thinking about it..."
|
|
|
|
def test_response_with_sources(self):
|
|
sources = [{"title": "doc.txt", "text": "content", "source": "/doc.txt"}]
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="Found it",
|
|
sources=sources,
|
|
tool_calls=[],
|
|
thought="",
|
|
model_name="agent",
|
|
)
|
|
assert resp["docsgpt"]["sources"] == sources
|
|
|
|
def test_response_with_tool_calls(self):
|
|
tool_calls = [{"tool_name": "notes", "call_id": "c1", "artifact_id": "a1"}]
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="Done",
|
|
sources=[],
|
|
tool_calls=tool_calls,
|
|
thought="",
|
|
model_name="agent",
|
|
)
|
|
assert resp["docsgpt"]["tool_calls"] == tool_calls
|
|
|
|
def test_pending_tool_calls_uses_tool_name(self):
|
|
"""Client tool responses use the original tool_name, not the LLM-visible action_name."""
|
|
pending = [
|
|
{
|
|
"call_id": "c1",
|
|
"tool_name": "get_weather",
|
|
"action_name": "get_weather",
|
|
"arguments": {"city": "SF"},
|
|
}
|
|
]
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="",
|
|
sources=[],
|
|
tool_calls=[],
|
|
thought="",
|
|
model_name="agent",
|
|
pending_tool_calls=pending,
|
|
)
|
|
tc = resp["choices"][0]["message"]["tool_calls"][0]
|
|
assert tc["function"]["name"] == "get_weather"
|
|
|
|
def test_pending_tool_calls_tool_name_takes_precedence(self):
|
|
"""When tool_name differs from action_name, tool_name is used."""
|
|
pending = [
|
|
{
|
|
"call_id": "c1",
|
|
"tool_name": "search",
|
|
"action_name": "search_1",
|
|
"arguments": {"q": "test"},
|
|
}
|
|
]
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="",
|
|
sources=[],
|
|
tool_calls=[],
|
|
thought="",
|
|
model_name="agent",
|
|
pending_tool_calls=pending,
|
|
)
|
|
tc = resp["choices"][0]["message"]["tool_calls"][0]
|
|
assert tc["function"]["name"] == "search"
|
|
|
|
def test_pending_tool_calls(self):
|
|
pending = [
|
|
{
|
|
"call_id": "c1",
|
|
"name": "get_weather",
|
|
"arguments": {"city": "SF"},
|
|
}
|
|
]
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="",
|
|
sources=[],
|
|
tool_calls=[],
|
|
thought="",
|
|
model_name="agent",
|
|
pending_tool_calls=pending,
|
|
)
|
|
assert resp["choices"][0]["finish_reason"] == "tool_calls"
|
|
assert resp["choices"][0]["message"]["content"] is None
|
|
assert len(resp["choices"][0]["message"]["tool_calls"]) == 1
|
|
tc = resp["choices"][0]["message"]["tool_calls"][0]
|
|
assert tc["id"] == "c1"
|
|
assert tc["function"]["name"] == "get_weather"
|
|
|
|
def test_no_docsgpt_when_empty(self):
|
|
resp = translate_response(
|
|
conversation_id="",
|
|
answer="Hi",
|
|
sources=None,
|
|
tool_calls=None,
|
|
thought="",
|
|
model_name="agent",
|
|
)
|
|
assert "docsgpt" not in resp
|
|
|
|
def test_finish_reason_override(self):
|
|
resp = translate_response(
|
|
conversation_id="c1",
|
|
answer="Partial output",
|
|
sources=None,
|
|
tool_calls=None,
|
|
thought="",
|
|
model_name="agent",
|
|
finish_reason_override="length",
|
|
)
|
|
assert resp["choices"][0]["finish_reason"] == "length"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_stream_event
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTranslateStreamEvent:
|
|
|
|
def test_answer_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "answer", "answer": "Hello"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["choices"][0]["delta"]["content"] == "Hello"
|
|
|
|
def test_thought_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "thought", "thought": "reasoning"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["choices"][0]["delta"]["reasoning_content"] == "reasoning"
|
|
|
|
def test_source_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "source", "source": [{"title": "t", "text": "x"}]},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["docsgpt"]["type"] == "source"
|
|
assert len(parsed["docsgpt"]["sources"]) == 1
|
|
|
|
def test_end_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "end"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 2
|
|
# First chunk: finish_reason stop
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["choices"][0]["finish_reason"] == "stop"
|
|
# Second chunk: [DONE]
|
|
assert chunks[1].strip() == "data: [DONE]"
|
|
|
|
def test_end_event_preserves_length_finish_reason(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "end", "finish_reason": "length"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["choices"][0]["finish_reason"] == "length"
|
|
|
|
def test_tool_call_client_execution(self):
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "tool_call",
|
|
"data": {
|
|
"call_id": "c1",
|
|
"action_name": "get_weather",
|
|
"arguments": {"city": "SF"},
|
|
"status": "requires_client_execution",
|
|
},
|
|
},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
tc = parsed["choices"][0]["delta"]["tool_calls"][0]
|
|
assert tc["id"] == "c1"
|
|
assert tc["function"]["name"] == "get_weather"
|
|
|
|
def test_tool_call_client_execution_uses_tool_name(self):
|
|
"""Streaming tool calls use tool_name (original name) for client responses."""
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "tool_call",
|
|
"data": {
|
|
"call_id": "c1",
|
|
"tool_name": "create",
|
|
"action_name": "create",
|
|
"arguments": {"title": "test"},
|
|
"status": "requires_client_execution",
|
|
},
|
|
},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
tc = parsed["choices"][0]["delta"]["tool_calls"][0]
|
|
assert tc["function"]["name"] == "create"
|
|
|
|
def test_tool_call_completed(self):
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "tool_call",
|
|
"data": {
|
|
"call_id": "c1",
|
|
"status": "completed",
|
|
"result": "done",
|
|
"artifact_id": "a1",
|
|
},
|
|
},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["docsgpt"]["type"] == "tool_call"
|
|
assert parsed["docsgpt"]["data"]["artifact_id"] == "a1"
|
|
|
|
def test_tool_calls_pending(self):
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "tool_calls_pending",
|
|
"data": {"pending_tool_calls": [{"call_id": "c1"}]},
|
|
},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 2
|
|
# Standard chunk with finish_reason tool_calls
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["choices"][0]["finish_reason"] == "tool_calls"
|
|
# Extension chunk
|
|
ext = json.loads(chunks[1].replace("data: ", "").strip())
|
|
assert ext["docsgpt"]["type"] == "tool_calls_pending"
|
|
|
|
def test_parallel_tool_indices_and_single_terminal_reason(self):
|
|
state = StreamTranslationState()
|
|
chunks = []
|
|
for call_id in ("c1", "c2"):
|
|
chunks += translate_stream_event(
|
|
{
|
|
"type": "tool_call",
|
|
"data": {
|
|
"call_id": call_id,
|
|
"action_name": "run",
|
|
"arguments": {},
|
|
"status": "requires_client_execution",
|
|
},
|
|
},
|
|
"chatcmpl-1",
|
|
"agent",
|
|
state=state,
|
|
)
|
|
chunks += translate_stream_event(
|
|
{"type": "tool_calls_pending", "data": {"pending_tool_calls": []}},
|
|
"chatcmpl-1",
|
|
"agent",
|
|
state=state,
|
|
)
|
|
chunks += translate_stream_event(
|
|
{"type": "end"}, "chatcmpl-1", "agent", state=state
|
|
)
|
|
decoded = [
|
|
json.loads(chunk.removeprefix("data: ").strip())
|
|
for chunk in chunks
|
|
if "[DONE]" not in chunk
|
|
]
|
|
indices = [
|
|
item["choices"][0]["delta"]["tool_calls"][0]["index"]
|
|
for item in decoded
|
|
if item["choices"][0]["delta"].get("tool_calls")
|
|
]
|
|
finishes = [
|
|
item["choices"][0]["finish_reason"]
|
|
for item in decoded
|
|
if item["choices"][0]["finish_reason"]
|
|
]
|
|
assert indices == [0, 1]
|
|
assert finishes == ["tool_calls"]
|
|
assert sum("[DONE]" in chunk for chunk in chunks) == 1
|
|
|
|
def test_id_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "id", "id": "conv-123"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["docsgpt"]["conversation_id"] == "conv-123"
|
|
|
|
def test_error_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "error", "error": "Something went wrong"},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["error"]["message"] == "Something went wrong"
|
|
|
|
def test_tool_calls_event_skipped(self):
|
|
"""The aggregate tool_calls event is redundant and should be skipped."""
|
|
chunks = translate_stream_event(
|
|
{"type": "tool_calls", "tool_calls": [{"call_id": "c1"}]},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 0
|
|
|
|
def test_research_events_skipped(self):
|
|
assert translate_stream_event(
|
|
{"type": "research_plan", "data": {}}, "id", "m"
|
|
) == []
|
|
assert translate_stream_event(
|
|
{"type": "research_progress", "data": {}}, "id", "m"
|
|
) == []
|
|
|
|
def test_awaiting_approval_as_extension(self):
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "tool_call",
|
|
"data": {"call_id": "c1", "status": "awaiting_approval"},
|
|
},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
assert len(chunks) == 1
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert parsed["docsgpt"]["type"] == "tool_call"
|
|
|
|
def test_standard_clients_can_ignore_docsgpt(self):
|
|
"""The docsgpt namespace rides on a valid empty chat.completion.chunk.
|
|
|
|
Standard OpenAI clients validate every streamed frame as a
|
|
``chat.completion.chunk``; a frame without ``choices`` is rejected.
|
|
So docsgpt-only events (sources, ids, tool_calls) are emitted as a
|
|
valid chunk with an empty delta plus a ``docsgpt`` extension that
|
|
standard parsers simply ignore.
|
|
"""
|
|
chunks = translate_stream_event(
|
|
{"type": "source", "source": [{"title": "t"}]},
|
|
"chatcmpl-1", "agent",
|
|
)
|
|
parsed = json.loads(chunks[0].replace("data: ", "").strip())
|
|
# Valid chunk envelope: standard parsers read choices[0].delta (empty)
|
|
assert isinstance(parsed.get("choices"), list)
|
|
assert parsed["choices"][0]["delta"] == {}
|
|
# docsgpt extension is present for clients that want it
|
|
assert "docsgpt" in parsed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_client_tool_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetClientToolName:
|
|
|
|
def test_uses_tool_name_when_present(self):
|
|
assert _get_client_tool_name({"tool_name": "create", "action_name": "create_1"}) == "create"
|
|
|
|
def test_falls_back_to_action_name(self):
|
|
assert _get_client_tool_name({"action_name": "get_weather"}) == "get_weather"
|
|
|
|
def test_falls_back_to_name(self):
|
|
assert _get_client_tool_name({"name": "search"}) == "search"
|
|
|
|
def test_returns_empty_when_no_fields(self):
|
|
assert _get_client_tool_name({}) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# content_to_text
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContentToText:
|
|
|
|
def test_plain_string_passthrough(self):
|
|
assert content_to_text("hello") == "hello"
|
|
|
|
def test_none_returns_empty(self):
|
|
assert content_to_text(None) == ""
|
|
|
|
def test_text_parts_joined(self):
|
|
content = [
|
|
{"type": "text", "text": "line one"},
|
|
{"type": "text", "text": "line two"},
|
|
]
|
|
assert content_to_text(content) == "line one\nline two"
|
|
|
|
def test_image_parts_dropped(self):
|
|
content = [
|
|
{"type": "text", "text": "describe this"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}},
|
|
]
|
|
# Only the text part contributes; the image is dropped from the flattened text.
|
|
assert content_to_text(content) == "describe this"
|
|
|
|
def test_bare_string_parts_included(self):
|
|
assert content_to_text(["a", "b"]) == "a\nb"
|
|
|
|
def test_missing_text_field_becomes_empty(self):
|
|
assert content_to_text([{"type": "text"}]) == ""
|
|
|
|
def test_non_string_non_list_coerced(self):
|
|
assert content_to_text(123) == "123"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_response_schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {"answer": {"type": "string"}},
|
|
"required": ["answer"],
|
|
}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractResponseSchema:
|
|
|
|
def test_none_when_absent(self):
|
|
assert extract_response_schema({}) is None
|
|
|
|
def test_response_format_json_schema_wrapper(self):
|
|
data = {
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {"name": "ans", "schema": _SCHEMA},
|
|
}
|
|
}
|
|
assert extract_response_schema(data) == _SCHEMA
|
|
|
|
def test_response_format_bare_schema_under_json_schema(self):
|
|
# A bare schema (no "schema" wrapper) but with a top-level "type" is tolerated.
|
|
data = {
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": _SCHEMA,
|
|
}
|
|
}
|
|
assert extract_response_schema(data) == _SCHEMA
|
|
|
|
def test_response_format_json_object_carries_no_schema(self):
|
|
data = {"response_format": {"type": "json_object"}}
|
|
assert extract_response_schema(data) is None
|
|
|
|
def test_response_schema_raw_object(self):
|
|
assert extract_response_schema({"response_schema": _SCHEMA}) == _SCHEMA
|
|
|
|
def test_response_schema_wrapper(self):
|
|
data = {"response_schema": {"schema": _SCHEMA}}
|
|
assert extract_response_schema(data) == _SCHEMA
|
|
|
|
def test_response_schema_takes_precedence_over_response_format(self):
|
|
other = {"type": "object", "properties": {}}
|
|
data = {
|
|
"response_schema": _SCHEMA,
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {"schema": other},
|
|
},
|
|
}
|
|
assert extract_response_schema(data) == _SCHEMA
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _split_leaked_reasoning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSplitLeakedReasoning:
|
|
|
|
def test_no_marker_is_noop(self):
|
|
assert _split_leaked_reasoning("just an answer") == ("just an answer", "")
|
|
|
|
def test_none_is_noop(self):
|
|
assert _split_leaked_reasoning(None) == (None, "")
|
|
|
|
def test_extracts_single_thought(self):
|
|
content = "{'type': 'thought', 'thought': 'let me think'}The answer is 42."
|
|
clean, leaked = _split_leaked_reasoning(content)
|
|
assert clean == "The answer is 42."
|
|
assert leaked == "let me think"
|
|
|
|
def test_extracts_multiple_thoughts(self):
|
|
content = (
|
|
"{'type': 'thought', 'thought': 'first'}"
|
|
"partial"
|
|
"{'type': 'thought', 'thought': 'second'}done"
|
|
)
|
|
clean, leaked = _split_leaked_reasoning(content)
|
|
assert clean == "partialdone"
|
|
assert leaked == "firstsecond"
|
|
|
|
def test_double_quoted_thought_value(self):
|
|
# When the token contains an apostrophe the repr uses double quotes.
|
|
content = "{'type': 'thought', 'thought': \"I'll check\"}answer"
|
|
clean, leaked = _split_leaked_reasoning(content)
|
|
assert clean == "answer"
|
|
assert leaked == "I'll check"
|
|
|
|
def test_thought_value_with_brace_not_truncated(self):
|
|
content = "{'type': 'thought', 'thought': 'use {json} here'}final"
|
|
clean, leaked = _split_leaked_reasoning(content)
|
|
assert clean == "final"
|
|
assert leaked == "use {json} here"
|
|
|
|
def test_strip_repr_quotes_unquoted_passthrough(self):
|
|
# Defensive branch: an unquoted value is returned unchanged.
|
|
assert _strip_repr_quotes("plain") == "plain"
|
|
assert _strip_repr_quotes("'quoted'") == "quoted"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_request — structured outputs / sampling / multimodal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTranslateRequestStructuredOutputs:
|
|
|
|
def test_response_format_surfaces_json_schema_strict_default(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {"name": "ans", "schema": _SCHEMA},
|
|
},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["json_schema"] == _SCHEMA
|
|
assert result["json_schema_strict"] is True
|
|
|
|
def test_response_format_honours_explicit_strict_false(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {"name": "ans", "schema": _SCHEMA, "strict": False},
|
|
},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["json_schema_strict"] is False
|
|
|
|
def test_json_object_mode_flag(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["json_object"] is True
|
|
assert "json_schema" not in result
|
|
|
|
def test_sampling_params_forwarded(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"temperature": 0.2,
|
|
"top_p": 0.9,
|
|
"seed": 7,
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["llm_params"] == {"temperature": 0.2, "top_p": 0.9, "seed": 7}
|
|
|
|
def test_tool_controls_are_forwarded(self):
|
|
choice = {"type": "function", "function": {"name": "read_file"}}
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"tools": [{
|
|
"type": "function",
|
|
"function": {"name": "read_file", "parameters": {}},
|
|
}],
|
|
"tool_choice": choice,
|
|
"parallel_tool_calls": False,
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["llm_params"]["tool_choice"] == choice
|
|
assert result["llm_params"]["parallel_tool_calls"] is False
|
|
|
|
def test_max_tokens_alias_dropped_when_canonical_present(self):
|
|
data = {
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"max_tokens": 100,
|
|
"max_completion_tokens": 200,
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["llm_params"]["max_completion_tokens"] == 200
|
|
assert "max_tokens" not in result["llm_params"]
|
|
|
|
def test_multimodal_content_preserved_and_question_flattened(self):
|
|
content = [
|
|
{"type": "text", "text": "what is this"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}},
|
|
]
|
|
data = {"messages": [{"role": "user", "content": content}]}
|
|
result = translate_request(data, "key")
|
|
assert result["question"] == "what is this"
|
|
assert result["multimodal_content"] == content
|
|
|
|
def test_plain_text_request_has_no_multimodal_content(self):
|
|
data = {"messages": [{"role": "user", "content": "plain"}]}
|
|
result = translate_request(data, "key")
|
|
assert "multimodal_content" not in result
|
|
|
|
def test_continuation_forwards_schema_and_sampling(self):
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "Hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
|
|
],
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {"name": "ans", "schema": _SCHEMA},
|
|
},
|
|
"temperature": 0.5,
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["tool_actions"]
|
|
assert result["json_schema"] == _SCHEMA
|
|
assert result["json_schema_strict"] is True
|
|
assert result["llm_params"] == {"temperature": 0.5}
|
|
|
|
def test_continuation_forwards_tools_and_json_object(self):
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "Hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
|
|
],
|
|
"tools": [{"type": "function", "function": {"name": "search"}}],
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
result = translate_request(data, "key")
|
|
assert result["client_tools"] == data["tools"]
|
|
assert result["json_object"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_response / translate_stream_event — reasoning-leak handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestReasoningLeakHandling:
|
|
|
|
def test_response_strips_leak_only_when_enabled(self):
|
|
answer = "{'type': 'thought', 'thought': 'hmm'}Final answer."
|
|
result = translate_response(
|
|
conversation_id="",
|
|
answer=answer,
|
|
sources=None,
|
|
tool_calls=None,
|
|
thought="",
|
|
model_name="agent",
|
|
strip_reasoning_leak=True,
|
|
)
|
|
msg = result["choices"][0]["message"]
|
|
assert msg["content"] == "Final answer."
|
|
assert msg["reasoning_content"] == "hmm"
|
|
|
|
def test_response_preserves_content_when_strip_disabled(self):
|
|
answer = "{'type': 'thought', 'thought': 'hmm'}Final answer."
|
|
result = translate_response(
|
|
conversation_id="",
|
|
answer=answer,
|
|
sources=None,
|
|
tool_calls=None,
|
|
thought="",
|
|
model_name="agent",
|
|
strip_reasoning_leak=False,
|
|
)
|
|
msg = result["choices"][0]["message"]
|
|
# Untouched: the leak marker is left in content verbatim.
|
|
assert msg["content"] == answer
|
|
assert "reasoning_content" not in msg
|
|
|
|
def test_response_combines_thought_and_leaked_reasoning(self):
|
|
answer = "{'type': 'thought', 'thought': 'leaked'}Answer."
|
|
result = translate_response(
|
|
conversation_id="",
|
|
answer=answer,
|
|
sources=None,
|
|
tool_calls=None,
|
|
thought="explicit ",
|
|
model_name="agent",
|
|
strip_reasoning_leak=True,
|
|
)
|
|
msg = result["choices"][0]["message"]
|
|
assert msg["reasoning_content"] == "explicit leaked"
|
|
|
|
def test_stream_answer_splits_leak_when_enabled(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "answer", "answer": "{'type': 'thought', 'thought': 'r'}hi"},
|
|
"chatcmpl-1", "agent", True,
|
|
)
|
|
deltas = [
|
|
json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"]
|
|
for c in chunks
|
|
]
|
|
assert {"reasoning_content": "r"} in deltas
|
|
assert {"content": "hi"} in deltas
|
|
|
|
def test_stream_answer_preserves_content_when_disabled(self):
|
|
raw = "{'type': 'thought', 'thought': 'r'}hi"
|
|
chunks = translate_stream_event(
|
|
{"type": "answer", "answer": raw}, "chatcmpl-1", "agent", False,
|
|
)
|
|
deltas = [
|
|
json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"]
|
|
for c in chunks
|
|
]
|
|
assert {"content": raw} in deltas
|
|
assert all("reasoning_content" not in d for d in deltas)
|
|
|
|
def test_stream_structured_answer_event(self):
|
|
chunks = translate_stream_event(
|
|
{"type": "structured_answer", "answer": '{"answer": "42"}'},
|
|
"chatcmpl-1", "agent", True,
|
|
)
|
|
deltas = [
|
|
json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"]
|
|
for c in chunks
|
|
]
|
|
assert {"content": '{"answer": "42"}'} in deltas
|
|
|
|
def test_stream_structured_answer_splits_leak(self):
|
|
chunks = translate_stream_event(
|
|
{
|
|
"type": "structured_answer",
|
|
"answer": "{'type': 'thought', 'thought': 'why'}{\"answer\": \"42\"}",
|
|
},
|
|
"chatcmpl-1", "agent", True,
|
|
)
|
|
deltas = [
|
|
json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"]
|
|
for c in chunks
|
|
]
|
|
assert {"reasoning_content": "why"} in deltas
|
|
assert {"content": '{"answer": "42"}'} in deltas
|