mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(examples): add a standalone text classification plugin A deployment-installed package contributes one model tool, classify_texts, and a status action through the full-stack plugin contract. The tool labels a list of texts against caller-supplied categories with a deployment-configured backend: Jev (one choice question per item over a shared batch state, checked per item) or an OpenAI-compatible chat endpoint (a JSON label list validated in code). Items are packed into requests under a size limit and sent with bounded concurrency; every failure is explicit per call or per item; credentials come only from deployment-named environment variables; item text never enters instructions, question keys, results or errors, and the served model name is kept to a short identifier. Each batch runs under an asyncio timeout bounded by the call deadline, so the whole call stays inside the host's 30-second limit, and ids and category names are bounded in bytes so 300 results fit the host's 64 KiB output limit. * docs(examples): add an opt-in live smoke test for the text classification plugin The script loads the package through the real extension path and classifies 20 synthetic English and Chinese sentences plus a blank and an over-long item with the configured backend, printing labels and statuses only. Against real Jev it agreed on all 20 labels in two requests and 1.6 seconds, with the served model reported as jev-1.13.0. * fix(examples): measure request size as sent and cover transport failures httpx serializes json= bodies with the json.dumps default, escaping every non-ASCII character as six bytes, while the size estimators measured raw UTF-8, so CJK batches could reach about twice the 256 KiB limit. The estimators now measure the same encoding; a test with CJK categories and texts checks the bytes the transport actually received. The network status gets a test through connect and protocol errors, and the deadline test's wall-clock slack is widened for loaded runners. * fix(examples): match classifier request wire encoding * fix(examples): bound classification output keys by JSON size --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
615 lines
30 KiB
Python
615 lines
30 KiB
Python
"""Standalone text-list classification plugin through the real plugin tool path."""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
from deerflow_extension_api.auth import ExtensionPrincipal
|
|
from deerflow_extension_api.plugins import ActionContext, ToolContext
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
|
|
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
|
from deerflow.extensions.plugin_tools import build_plugin_tools, plugin_tool_name
|
|
|
|
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-classify"
|
|
REAL_ASYNC_CLIENT = httpx.AsyncClient
|
|
KEY = "test-only-not-a-real-key"
|
|
CATEGORIES = [{"name": "billing", "description": "Payments, invoices and refunds."}, {"name": "technical", "description": "Product bugs and integration failures."}]
|
|
LLM = {"backend": "llm", "llm_url": "https://llm.example/v1/chat/completions", "llm_model": "test-model"}
|
|
|
|
|
|
@pytest.fixture
|
|
def load(monkeypatch):
|
|
monkeypatch.syspath_prepend(str(EXAMPLE))
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", KEY)
|
|
monkeypatch.setenv("CLASSIFY_LLM_API_KEY", KEY)
|
|
|
|
def _load(**config):
|
|
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_jev_classify:install", config={"enabled": True, **config})])
|
|
assert not diagnostics, diagnostics
|
|
return loaded
|
|
|
|
return _load
|
|
|
|
|
|
def handler(loaded):
|
|
((_, plugin),) = loaded.plugins
|
|
(tool,) = plugin.tools
|
|
return tool.handler
|
|
|
|
|
|
def context():
|
|
return ToolContext(ExtensionPrincipal("alice"), {"enabled": True}, "thread-1")
|
|
|
|
|
|
def items(count, prefix="item"):
|
|
return [{"id": f"{prefix}-{index}", "text": f"invoice {index}" if index % 2 else f"crash {index}"} for index in range(1, count + 1)]
|
|
|
|
|
|
def payload(count=3, **extra):
|
|
return {"items": items(count), "categories": CATEGORIES, **extra}
|
|
|
|
|
|
def statuses(result):
|
|
return [(entry["status"], entry["error"]) for entry in result["results"]]
|
|
|
|
|
|
def transport(monkeypatch, respond):
|
|
"""Route every AsyncClient through a mock; ``respond`` may be sync or async."""
|
|
requests = []
|
|
|
|
async def handle(request):
|
|
requests.append(request)
|
|
result = respond(request, json.loads(request.content))
|
|
return await result if asyncio.iscoroutine(result) else result
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handle), **kw))
|
|
return requests
|
|
|
|
|
|
def jev_answers(body, label_of=None):
|
|
answers = {}
|
|
for key in body["questions"]:
|
|
text = body["state"][int(key.rsplit("_", 1)[1]) - 1]["text"]
|
|
label = label_of(text) if label_of else ("billing" if "invoice" in text else "technical")
|
|
answers[key] = {"type": "choice", "choice": label, "probabilities": {label: 0.9}, "confidence": 0.9}
|
|
return answers
|
|
|
|
|
|
def jev_ok(request, body):
|
|
return httpx.Response(200, json={"model": "jev-1.13.0", "answers": jev_answers(body), "request_id": "req-1"})
|
|
|
|
|
|
def chat_ok(request, body):
|
|
sent = json.loads(body["messages"][-1]["content"])["items"]
|
|
labels = [{"id": item["id"], "label": "billing" if "invoice" in item["text"] else "technical"} for item in sent]
|
|
return httpx.Response(200, json={"model": "test-model-2026", "choices": [{"message": {"content": json.dumps({"labels": labels})}}]})
|
|
|
|
|
|
def test_install_registers_one_model_tool_and_a_disabled_config_hides_it(load):
|
|
loaded = load()
|
|
((_, plugin),) = loaded.plugins
|
|
assert plugin.namespace == "community.jev-classify"
|
|
(tool,) = build_plugin_tools(loaded)
|
|
assert tool.name == plugin_tool_name("community.jev-classify", "classify_texts")
|
|
assert "runtime" not in json.dumps(tool.tool_call_schema)
|
|
assert build_plugin_tools(load(enabled=False)) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jev_backend_asks_one_choice_question_per_item_and_labels_in_input_order(load, monkeypatch):
|
|
requests = transport(monkeypatch, jev_ok)
|
|
result = await handler(load())(payload(instruction="Use the customer's main request."), context())
|
|
(request,) = requests
|
|
assert request.headers["authorization"] == f"Bearer {KEY}"
|
|
assert str(request.url) == "https://api.typesafe.ai/v1/systemone"
|
|
body = json.loads(request.content)
|
|
assert body["model"] == "jev-latest"
|
|
assert body["state"] == [{"id": 1, "text": "invoice 1"}, {"id": 2, "text": "crash 2"}, {"id": 3, "text": "invoice 3"}]
|
|
assert list(body["questions"]) == ["item_1", "item_2", "item_3"]
|
|
for index, question in enumerate(body["questions"].values(), 1):
|
|
assert question["type"] == "choice"
|
|
assert question["criteria"] == {"billing": "Payments, invoices and refunds.", "technical": "Product bugs and integration failures."}
|
|
assert "Use the customer's main request." in question["instructions"]
|
|
assert f"id is {index}" in question["instructions"]
|
|
# Caller ids are reconciled by position; they never become wire ids or question keys.
|
|
assert "item-1" not in json.dumps(body)
|
|
assert result == {
|
|
"backend": "jev",
|
|
"model": "jev-1.13.0",
|
|
"results": [
|
|
{"id": "item-1", "label": "billing", "status": "ok", "error": ""},
|
|
{"id": "item-2", "label": "technical", "status": "ok", "error": ""},
|
|
{"id": "item-3", "label": "billing", "status": "ok", "error": ""},
|
|
],
|
|
"counts": {"ok": 3},
|
|
"requests": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_items_are_batched_and_unusable_items_are_reported_without_being_sent(load, monkeypatch):
|
|
requests = transport(monkeypatch, jev_ok)
|
|
data = payload(25)
|
|
data["items"][3]["text"] = " "
|
|
data["items"][7]["text"] = "x" * 51
|
|
result = await handler(load(batch_size=10, concurrency=2, max_text_chars=50))(data, context())
|
|
assert sorted(len(json.loads(r.content)["state"]) for r in requests) == [3, 10, 10]
|
|
assert [r["id"] for r in result["results"]] == [item["id"] for item in data["items"]]
|
|
assert result["results"][3] == {"id": "item-4", "label": None, "status": "empty_text", "error": ""}
|
|
assert result["results"][7] == {"id": "item-8", "label": None, "status": "too_long", "error": ""}
|
|
assert all(r["label"] == ("billing" if "invoice" in item["text"] else "technical") for r, item in zip(result["results"], data["items"], strict=True) if r["status"] == "ok")
|
|
assert result["counts"] == {"ok": 23, "empty_text": 1, "too_long": 1}
|
|
assert result["requests"] == 3
|
|
sent = json.dumps([json.loads(r.content)["state"] for r in requests])
|
|
assert " " not in sent and "x" * 51 not in sent
|
|
|
|
|
|
@pytest.mark.parametrize("config", [{}, LLM], ids=["jev", "llm"])
|
|
@pytest.mark.asyncio
|
|
async def test_requests_are_packed_under_the_size_limit_when_categories_or_texts_are_large(load, monkeypatch, config):
|
|
requests = transport(monkeypatch, chat_ok if config else jev_ok)
|
|
categories = [{"name": f"category_{index:02d}", "description": "d" * 600} for index in range(32)]
|
|
categories[0] = {"name": "billing", "description": "Payments."}
|
|
data = {"items": [{"id": str(index), "text": ("invoice " * 250)[:2000]} for index in range(1, 41)], "categories": categories}
|
|
result = await handler(load(batch_size=20, max_text_chars=2000, **config))(data, context())
|
|
assert result["counts"] == {"ok": 40}
|
|
# Jev repeats the criteria in every question, so the byte limit splits the two 20-item batches further; chat sends them once per request.
|
|
assert len(requests) == 2 if config else len(requests) >= 3
|
|
assert all(len(r.content) <= 256 * 1024 for r in requests)
|
|
assert sum(len(json.loads(r.content)["state"]) if not config else len(json.loads(json.loads(r.content)["messages"][-1]["content"])["items"]) for r in requests) == 40
|
|
|
|
|
|
@pytest.mark.parametrize("config", [{}, LLM], ids=["jev", "llm"])
|
|
@pytest.mark.asyncio
|
|
async def test_packing_uses_compact_utf8_wire_size_for_non_ascii_text(load, monkeypatch, config):
|
|
# httpx sends compact UTF-8 JSON; CJK text must not be budgeted as Unicode escapes.
|
|
def jev_billing(request, body):
|
|
return httpx.Response(200, json={"model": "jev", "answers": jev_answers(body, lambda text: "billing")})
|
|
|
|
def chat_billing(request, body):
|
|
sent = json.loads(body["messages"][-1]["content"])["items"]
|
|
return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"labels": [{"id": item["id"], "label": "billing"} for item in sent]})}}]})
|
|
|
|
requests = transport(monkeypatch, chat_billing if config else jev_billing)
|
|
categories = [{"name": f"类别{index:02d}", "description": "描" * 600} for index in range(32)]
|
|
categories[0] = {"name": "billing", "description": "付款、发票与退款。"}
|
|
data = {"items": [{"id": str(index), "text": ("发票 " * 700)[:2000]} for index in range(1, 41)], "categories": categories}
|
|
result = await handler(load(batch_size=10, max_text_chars=2000, **config))(data, context())
|
|
assert result["counts"] == {"ok": 40}
|
|
# Measured on the bytes the mock transport actually received, not on the estimate.
|
|
assert all(len(r.content) <= 256 * 1024 for r in requests)
|
|
# Jev repeats the criteria per question (four items fit), chat sends them once (all ten fit).
|
|
assert len(requests) == (4 if config else 10)
|
|
assert sum(len(json.loads(r.content)["state"]) if not config else len(json.loads(json.loads(r.content)["messages"][-1]["content"])["items"]) for r in requests) == 40
|
|
|
|
|
|
@pytest.mark.parametrize("value", [{"text": "plain"}, {"text": "中文🙂"}, {"message": 'quote"\\\n\t'}], ids=["ascii", "unicode", "escapes"])
|
|
def test_wire_size_matches_httpx_json_encoding(load, value):
|
|
load()
|
|
from deerflow_extension_jev_classify.classify import _wire_size
|
|
|
|
request = httpx.Request("POST", "https://classifier.example", json=value)
|
|
assert _wire_size(value) == len(request.content)
|
|
|
|
|
|
@pytest.mark.parametrize(("text", "count"), [('"' * 20000, 6), ("\\" * 20000, 6), ("a\n" * 10000, 8)], ids=["quotes", "backslashes", "newlines"])
|
|
@pytest.mark.asyncio
|
|
async def test_chat_packing_counts_embedded_json_escaping(load, monkeypatch, text, count):
|
|
requests = transport(monkeypatch, chat_ok)
|
|
data = {"items": [{"id": str(index), "text": text} for index in range(count)], "categories": CATEGORIES}
|
|
# This is valid under the host's separate input-byte limit.
|
|
assert len(json.dumps(data, allow_nan=False).encode("utf-8")) <= 256 * 1024
|
|
|
|
result = await handler(load(batch_size=10, max_text_chars=20000, **LLM))(data, context())
|
|
|
|
assert result["counts"] == {"ok": count}
|
|
assert len(requests) == 2
|
|
assert all(len(request.content) <= 256 * 1024 for request in requests)
|
|
sent = [item["text"] for request in requests for item in json.loads(json.loads(request.content)["messages"][-1]["content"])["items"]]
|
|
assert sent == [text] * count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrency_is_bounded(load, monkeypatch):
|
|
in_flight, peak = [0], [0]
|
|
|
|
async def slow(request, body):
|
|
in_flight[0] += 1
|
|
peak[0] = max(peak[0], in_flight[0])
|
|
await asyncio.sleep(0.05)
|
|
in_flight[0] -= 1
|
|
return jev_ok(request, body)
|
|
|
|
transport(monkeypatch, slow)
|
|
result = await handler(load(batch_size=2, concurrency=3))(payload(20), context())
|
|
assert result["counts"] == {"ok": 20} and result["requests"] == 10
|
|
assert peak[0] == 3
|
|
|
|
|
|
def missing_answer(body):
|
|
answers = jev_answers(body)
|
|
answers.pop(next(iter(answers)))
|
|
return {"model": "jev", "answers": answers}
|
|
|
|
|
|
def label_outside(body):
|
|
answers = jev_answers(body)
|
|
answers[next(iter(answers))]["choice"] = "shipping"
|
|
return {"model": "jev", "answers": answers}
|
|
|
|
|
|
def choice_not_a_string(body):
|
|
answers = jev_answers(body)
|
|
answers[next(iter(answers))]["choice"] = None
|
|
return {"model": "jev", "answers": answers}
|
|
|
|
|
|
def wrong_type(body):
|
|
answers = jev_answers(body)
|
|
answers[next(iter(answers))]["type"] = "score"
|
|
return {"model": "jev", "answers": answers}
|
|
|
|
|
|
def broken_batch(broken):
|
|
def respond(request, body):
|
|
if any(item["text"] == "crash 12" for item in body["state"]):
|
|
value = broken(body)
|
|
return httpx.Response(200, json=value) if isinstance(value, dict) else httpx.Response(200, content=value.encode())
|
|
return jev_ok(request, body)
|
|
|
|
return respond
|
|
|
|
|
|
@pytest.mark.parametrize("broken", [missing_answer, label_outside, choice_not_a_string, wrong_type], ids=["missing_answer", "label_outside_categories", "choice_not_a_string", "wrong_answer_type"])
|
|
@pytest.mark.asyncio
|
|
async def test_a_malformed_jev_answer_fails_only_that_item(load, monkeypatch, broken):
|
|
transport(monkeypatch, broken_batch(broken))
|
|
result = await handler(load(batch_size=10, concurrency=1))(payload(20), context())
|
|
assert statuses(result) == [("ok", "")] * 10 + [("error", "invalid_response")] + [("ok", "")] * 9
|
|
assert result["results"][10]["label"] is None
|
|
assert result["counts"] == {"ok": 19, "error": 1} and result["requests"] == 2
|
|
|
|
|
|
@pytest.mark.parametrize("broken", [lambda body: {"model": "jev", "answers": ["not", "a", "mapping"]}, lambda body: "not json at all"], ids=["answers_not_a_mapping", "body_not_json"])
|
|
@pytest.mark.asyncio
|
|
async def test_an_unusable_jev_body_fails_only_its_own_batch(load, monkeypatch, broken):
|
|
transport(monkeypatch, broken_batch(broken))
|
|
result = await handler(load(batch_size=10, concurrency=1))(payload(20), context())
|
|
assert statuses(result) == [("ok", "")] * 10 + [("error", "invalid_response")] * 10
|
|
assert result["counts"] == {"ok": 10, "error": 10} and result["requests"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_labels_are_canonicalised_by_case_and_whitespace_only_when_unambiguous(load, monkeypatch):
|
|
transport(monkeypatch, lambda request, body: httpx.Response(200, json={"model": "jev", "answers": jev_answers(body, lambda text: " Billing " if "invoice" in text else "TECHNICAL")}))
|
|
result = await handler(load())(payload(), context())
|
|
assert [r["label"] for r in result["results"]] == ["billing", "technical", "billing"]
|
|
transport(monkeypatch, lambda request, body: httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"labels": [{"id": "1", "label": "Billing"}, {"id": "2", "label": "billing"}]})}}]}))
|
|
ambiguous = {"items": items(2), "categories": [{"name": "billing"}, {"name": "Billing"}]}
|
|
result = await handler(load(**LLM))(ambiguous, context())
|
|
assert [r["label"] for r in result["results"]] == ["Billing", "billing"]
|
|
result = await handler(load(**LLM))({"items": items(2), "categories": [{"name": "billing"}, {"name": "technical"}]}, context())
|
|
assert result["results"][0]["label"] == "billing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_failures_are_reported_per_batch_and_an_auth_or_rate_limit_failure_stops_the_rest(load, monkeypatch):
|
|
calls = []
|
|
|
|
def respond(request, body):
|
|
calls.append(body)
|
|
if len(calls) == 1:
|
|
return httpx.Response(500, content=b"private upstream failure detail")
|
|
return jev_ok(request, body)
|
|
|
|
transport(monkeypatch, respond)
|
|
result = await handler(load(batch_size=10, concurrency=1))(payload(30), context())
|
|
assert statuses(result) == [("error", "http_500")] * 10 + [("ok", "")] * 20
|
|
assert "private upstream failure detail" not in json.dumps(result)
|
|
|
|
for status in (401, 429):
|
|
transport(monkeypatch, lambda request, body, status=status: httpx.Response(status, json={"error": "bad key"}))
|
|
result = await handler(load(batch_size=10, concurrency=1))(payload(30), context())
|
|
assert statuses(result) == [("error", f"http_{status}")] * 10 + [("not_processed", "stopped")] * 20
|
|
assert result["requests"] == 1
|
|
assert result["counts"] == {"error": 10, "not_processed": 20}
|
|
|
|
|
|
@pytest.mark.parametrize(("failure", "code"), [(httpx.ReadTimeout, "timeout"), (httpx.ConnectError, "network"), (httpx.RemoteProtocolError, "network")], ids=["read_timeout", "connect_error", "protocol_error"])
|
|
@pytest.mark.asyncio
|
|
async def test_transport_failures_are_reported_per_item_without_upstream_detail(load, monkeypatch, failure, code):
|
|
def fail(request, body):
|
|
raise failure("private upstream details")
|
|
|
|
transport(monkeypatch, fail)
|
|
result = await handler(load(batch_size=10))(payload(20), context())
|
|
assert set(statuses(result)) == {("error", code)}
|
|
assert "private upstream details" not in json.dumps(result)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_call_deadline_bounds_in_flight_batches_and_skips_the_rest(load, monkeypatch):
|
|
release = asyncio.Event()
|
|
|
|
async def hang(request, body):
|
|
# A socket-level timeout never fires on a mock transport, so only an asyncio bound can end this.
|
|
await release.wait()
|
|
return jev_ok(request, body)
|
|
|
|
requests = transport(monkeypatch, hang)
|
|
started = time.monotonic()
|
|
result = await handler(load(batch_size=10, concurrency=2, deadline_seconds=1))(payload(40), context())
|
|
elapsed = time.monotonic() - started
|
|
assert statuses(result) == [("error", "timeout")] * 20 + [("not_processed", "deadline")] * 20
|
|
assert result["requests"] == 2 and len(requests) == 2
|
|
# The upstream never answers, so returning at all proves the asyncio bound; the slack only guards tightness on a loaded runner.
|
|
assert elapsed < 3.0
|
|
release.set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_propagates_and_closes_the_client(load, monkeypatch):
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
closed = []
|
|
|
|
async def hang(request, body):
|
|
started.set()
|
|
await release.wait()
|
|
return jev_ok(request, body)
|
|
|
|
transport(monkeypatch, hang)
|
|
original_aclose = httpx.MockTransport.aclose
|
|
|
|
async def aclose(self):
|
|
closed.append(True)
|
|
await original_aclose(self)
|
|
|
|
monkeypatch.setattr(httpx.MockTransport, "aclose", aclose)
|
|
task = asyncio.create_task(handler(load())(payload(), context()))
|
|
await asyncio.wait_for(started.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert closed == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_backend_bug_fails_its_batch_not_the_call(load, monkeypatch, caplog):
|
|
import deerflow_extension_jev_classify.classify as module
|
|
|
|
async def boom(self, client, texts):
|
|
raise RuntimeError("private detail " + texts[0])
|
|
|
|
monkeypatch.setattr(module.JevBackend, "classify", boom)
|
|
transport(monkeypatch, jev_ok)
|
|
result = await handler(load(batch_size=10, concurrency=1))(payload(20), context())
|
|
assert set(statuses(result)) == {("error", "internal")}
|
|
assert result["counts"] == {"error": 20}
|
|
assert "private detail" not in json.dumps(result) and "invoice 1" not in caplog.text
|
|
assert "RuntimeError" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_backend_returns_schema_validated_labels_from_a_host_named_key(load, monkeypatch):
|
|
requests = transport(monkeypatch, chat_ok)
|
|
result = await handler(load(**LLM))(payload(), context())
|
|
(request,) = requests
|
|
assert str(request.url) == LLM["llm_url"]
|
|
assert request.headers["authorization"] == f"Bearer {KEY}"
|
|
body = json.loads(request.content)
|
|
assert body["model"] == "test-model"
|
|
assert body["response_format"] == {"type": "json_object"}
|
|
assert body["temperature"] == 0
|
|
assert "billing" in body["messages"][0]["content"] and "Product bugs" in body["messages"][0]["content"]
|
|
assert json.loads(body["messages"][1]["content"]) == {"items": [{"id": "1", "text": "invoice 1"}, {"id": "2", "text": "crash 2"}, {"id": "3", "text": "invoice 3"}]}
|
|
assert result["backend"] == "llm" and result["model"] == "test-model-2026"
|
|
assert [r["label"] for r in result["results"]] == ["billing", "technical", "billing"]
|
|
assert result["counts"] == {"ok": 3}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
"not json",
|
|
json.dumps({"labels": "billing"}),
|
|
json.dumps({"labels": [{"id": "1", "label": "billing"}, {"id": "2", "label": "technical"}]}),
|
|
json.dumps({"labels": [{"id": "1", "label": "billing"}, {"id": "2", "label": "technical"}, {"id": "3", "label": "shipping"}]}),
|
|
json.dumps({"labels": [{"id": "1", "label": "billing"}, {"id": "1", "label": "billing"}, {"id": "3", "label": "billing"}]}),
|
|
json.dumps({"labels": [{"id": 1, "label": "billing"}, {"id": 2, "label": "billing"}, {"id": 3, "label": "billing"}]}),
|
|
],
|
|
ids=["not_json", "labels_not_a_list", "missing_item", "label_outside_categories", "duplicate_id", "numeric_ids"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_chat_backend_rejects_output_that_does_not_match_the_contract(load, monkeypatch, content):
|
|
transport(monkeypatch, lambda request, body: httpx.Response(200, json={"choices": [{"message": {"content": content}}]}))
|
|
result = await handler(load(**LLM))(payload(), context())
|
|
assert set(statuses(result)) == {("error", "invalid_response")}
|
|
assert all(r["label"] is None for r in result["results"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_category_names_with_json_syntax_survive_the_chat_prompt(load, monkeypatch):
|
|
names = ['bi"ll{ing}', "tech\nnical"]
|
|
|
|
def respond(request, body):
|
|
sent = json.loads(body["messages"][-1]["content"])["items"]
|
|
return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"labels": [{"id": item["id"], "label": names[0] if "invoice" in item["text"] else names[1]} for item in sent]})}}]})
|
|
|
|
requests = transport(monkeypatch, respond)
|
|
result = await handler(load(**LLM))({"items": items(2), "categories": [{"name": names[0]}, {"name": names[1], "description": "d"}]}, context())
|
|
system = json.loads(requests[0].content)["messages"][0]["content"]
|
|
assert json.loads(system[system.index("Categories: ") + len("Categories: ") :]) == {names[0]: names[0], names[1]: "d"}
|
|
assert [r["label"] for r in result["results"]] == names
|
|
|
|
|
|
@pytest.mark.parametrize("config", [{}, LLM], ids=["jev", "llm"])
|
|
@pytest.mark.asyncio
|
|
async def test_a_backend_without_credentials_reports_an_explicit_error_without_any_request(load, monkeypatch, config):
|
|
requests = transport(monkeypatch, jev_ok)
|
|
loaded = load(**config)
|
|
monkeypatch.delenv("TYPESAFE_API_KEY")
|
|
monkeypatch.delenv("CLASSIFY_LLM_API_KEY")
|
|
result = await handler(loaded)(payload(), context())
|
|
assert result["error"]["code"] == "backend_not_configured"
|
|
assert "TYPESAFE" not in json.dumps(result) and "CLASSIFY_LLM" not in json.dumps(result)
|
|
assert requests == []
|
|
|
|
|
|
LONG_KEY = "类" * 21 + "x" # 64 bytes of UTF-8 but 22 characters
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "code"),
|
|
[
|
|
({"items": items(2) + [{"id": "item-1", "text": "again"}], "categories": CATEGORIES}, "invalid_items"),
|
|
({"items": [{"id": LONG_KEY + "y", "text": "invoice"}], "categories": CATEGORIES}, "invalid_items"),
|
|
({"items": items(2), "categories": CATEGORIES + [{"name": "billing", "description": "again"}]}, "invalid_categories"),
|
|
({"items": items(2), "categories": CATEGORIES + [{"name": LONG_KEY + "y"}]}, "invalid_categories"),
|
|
({"items": items(6), "categories": CATEGORIES}, "too_many_items"),
|
|
],
|
|
ids=["duplicate_ids", "id_over_64_bytes", "duplicate_category_names", "category_name_over_64_bytes", "over_the_configured_item_limit"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_requests_are_explicit_errors_without_any_request(load, monkeypatch, data, code):
|
|
requests = transport(monkeypatch, jev_ok)
|
|
result = await handler(load(max_items=5))(data, context())
|
|
assert result["error"]["code"] == code
|
|
assert result["error"]["message"]
|
|
assert requests == []
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ["ok", "invalid_response"])
|
|
@pytest.mark.asyncio
|
|
async def test_results_for_the_largest_allowed_call_fit_the_host_output_bound(load, monkeypatch, outcome):
|
|
names = ["类" * 20 + f"{index:04d}" for index in range(32)] # 64 bytes each
|
|
|
|
def respond(request, body):
|
|
if outcome == "ok":
|
|
return httpx.Response(200, json={"model": "jev", "answers": jev_answers(body, lambda text: names[int(text) % 32])})
|
|
return httpx.Response(200, json={"model": "jev", "answers": {}})
|
|
|
|
transport(monkeypatch, respond)
|
|
data = {"items": [{"id": "类" * 20 + f"{index:04d}", "text": str(index)} for index in range(300)], "categories": [{"name": name} for name in names]}
|
|
result = await handler(load(max_items=300, batch_size=20, concurrency=8))(data, context())
|
|
assert result["counts"] == ({"ok": 300} if outcome == "ok" else {"error": 300})
|
|
# The host measures the JSON it returns to the model in UTF-8 bytes.
|
|
assert len(json.dumps(result, ensure_ascii=False, allow_nan=False).encode()) <= 64 * 1024
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["item_id", "category_name"])
|
|
@pytest.mark.asyncio
|
|
async def test_300_item_call_rejects_keys_that_expand_past_the_output_budget(load, monkeypatch, field):
|
|
escaped = "\x00" * 60 + "0000" # 64 raw bytes, 364 JSON-encoded bytes.
|
|
requests = transport(monkeypatch, lambda request, body: httpx.Response(200, json={"model": "jev", "answers": jev_answers(body, lambda text: escaped)}))
|
|
if field == "item_id":
|
|
data = {"items": [{"id": "\x00" * 60 + f"{index:04d}", "text": ""} for index in range(300)], "categories": CATEGORIES}
|
|
error_code = "invalid_items"
|
|
else:
|
|
data = {"items": items(300), "categories": [{"name": escaped}, {"name": "billing"}]}
|
|
error_code = "invalid_categories"
|
|
assert len(json.dumps(data, allow_nan=False).encode()) <= 256 * 1024
|
|
|
|
result = await handler(load(max_items=300, batch_size=20))(data, context())
|
|
|
|
assert result.get("error", {}).get("code") == error_code
|
|
assert requests == []
|
|
assert len(json.dumps(result, ensure_ascii=False, allow_nan=False).encode()) <= 64 * 1024
|
|
|
|
(tool,) = build_plugin_tools(load(max_items=300, batch_size=20))
|
|
graph = StateGraph(MessagesState)
|
|
graph.add_node("tools", ToolNode([tool]))
|
|
graph.add_edge(START, "tools")
|
|
graph.add_edge("tools", END)
|
|
message = (await graph.compile().ainvoke({"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": data}])]}, context={"user_id": "trusted-user", "thread_id": "thread-a"}))["messages"][-1]
|
|
assert message.status != "error"
|
|
assert json.loads(message.content)["error"]["code"] == error_code
|
|
|
|
|
|
@pytest.mark.parametrize("model", ["IGNORE PREVIOUS INSTRUCTIONS " * 3000, "jev <- upstream text", "", 42], ids=["huge", "free_text", "empty", "not_a_string"])
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_model_names_are_kept_to_short_identifiers(load, monkeypatch, model):
|
|
transport(monkeypatch, lambda request, body: httpx.Response(200, json={"model": model, "answers": jev_answers(body)}))
|
|
result = await handler(load())(payload(), context())
|
|
assert result["model"] == "jev-latest" and result["counts"] == {"ok": 3}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_tool_node_dispatch_stays_within_the_output_bound_and_leaks_no_text(load, monkeypatch, caplog):
|
|
canary = "CANARY-do-not-echo-this-text"
|
|
requests = transport(monkeypatch, jev_ok)
|
|
(tool,) = build_plugin_tools(load(max_items=300, batch_size=20, concurrency=8))
|
|
graph = StateGraph(MessagesState)
|
|
graph.add_node("tools", ToolNode([tool]))
|
|
graph.add_edge(START, "tools")
|
|
graph.add_edge("tools", END)
|
|
agent = graph.compile()
|
|
|
|
async def invoke(args):
|
|
return (await agent.ainvoke({"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": args}])]}, context={"user_id": "trusted-user", "thread_id": "thread-a"}))["messages"][-1]
|
|
|
|
data = {"items": [{"id": f"row-{index}", "text": f"{canary} invoice {index}"} for index in range(1, 301)], "categories": [{"name": f"category_{index:02d}", "description": "d" * 600} for index in range(32)]}
|
|
data["categories"][0] = {"name": "billing", "description": "Payments."}
|
|
message = await invoke(data)
|
|
assert message.status != "error"
|
|
content = json.loads(message.content)
|
|
assert len(message.content.encode()) <= 64 * 1024
|
|
assert content["counts"] == {"ok": 300} and content["requests"] == len(requests)
|
|
assert canary not in message.content and canary not in caplog.text and KEY not in caplog.text
|
|
assert (await invoke({"items": [{"id": "x", "text": "hello"}]})).status == "error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_action_reports_configuration_only(load, monkeypatch):
|
|
((_, plugin),) = load().plugins
|
|
(action,) = plugin.backend
|
|
assert action.name == "status"
|
|
status = await action.handler({}, ActionContext(ExtensionPrincipal("alice"), {"enabled": True}))
|
|
assert status == {"enabled": True, "backend": "jev", "configured": True, "batch_size": 10, "max_items": 200}
|
|
monkeypatch.delenv("TYPESAFE_API_KEY")
|
|
assert (await action.handler({}, ActionContext(ExtensionPrincipal("alice"), {"enabled": True})))["configured"] is False
|
|
with pytest.raises(ValueError):
|
|
await action.handler({"anything": True}, ActionContext(ExtensionPrincipal("alice"), {"enabled": True}))
|
|
assert "TYPESAFE" not in repr(plugin) and KEY not in repr(plugin)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"config",
|
|
[
|
|
{"backend": "llm"},
|
|
{"batch_size": 0},
|
|
{"unknown": True},
|
|
{"jev_api_key_env": "not a name"},
|
|
{"llm_url": "http://llm.example/v1/chat/completions", "llm_model": "m", "backend": "llm"},
|
|
{"jev_base_url": "https://api.typesafe.ai:abc"},
|
|
{"jev_base_url": "https://api.typesafe.ai?x=1"},
|
|
{"jev_base_url": "https://user:pw@api.typesafe.ai"},
|
|
{"deadline_seconds": 29},
|
|
{"jev_model": "jev latest"},
|
|
{"jev_api_key_env": "never-accept-inline-secrets"},
|
|
],
|
|
ids=[
|
|
"llm_without_endpoint",
|
|
"zero_batch",
|
|
"unknown_field",
|
|
"bad_env_name",
|
|
"plaintext_remote_endpoint",
|
|
"bad_port",
|
|
"query_in_endpoint",
|
|
"credentials_in_endpoint",
|
|
"deadline_over_host_limit",
|
|
"model_not_an_identifier",
|
|
"secret_like_env_name",
|
|
],
|
|
)
|
|
def test_invalid_deployment_options_are_rejected_at_install(monkeypatch, config):
|
|
monkeypatch.syspath_prepend(str(EXAMPLE))
|
|
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_jev_classify:install", config={"enabled": True, **config})])
|
|
assert diagnostics and not loaded.plugins
|
|
assert "never-accept-inline-secrets" not in " ".join(str(d) for d in diagnostics)
|