Files
deerflow-code/scripts/test-model-tool-stream.py

918 lines
33 KiB
Python

#!/usr/bin/env python3
"""Probe whether an OpenAI-compatible model endpoint streams tool arguments.
This script talks directly to the configured model endpoint, bypassing
DeerFlow/LangGraph/the sandbox/frontend. It never prints the API key, prompt,
generated text, or tool-argument contents; only timing and byte/character
counts are reported.
Environment variables:
DF_LLM_URL Base URL ending in /v1, or the full /chat/completions URL.
DF_LLM_MODEL Model name sent to the endpoint.
DF_LLM_KEY Optional Bearer token. Keep it in the environment so it does
not appear in shell history or the process command line.
Examples:
python scripts/test-model-tool-stream.py --mode tool
python scripts/test-model-tool-stream.py --mode both --lines 120 \
--log-file ./model-stream-test.log
The default run executes both a normal text-stream control and a forced
write_file tool-call probe. Output is shown in the terminal and copied to a
timestamped log file. Exit code 0 means every requested probe showed genuine
time-separated streaming; 1 means a single block/burst/malformed result; 2+
means a request or script error. The complete RESULT_JSON lines are intended
for follow-up diagnosis.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import ssl
import sys
import time
from collections.abc import Iterator
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, TextIO
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen
SCRIPT_VERSION = "1.2"
MAX_NON_SSE_BYTES = 64 * 1024 * 1024
_SHORT_OUTCOME_CODES = {
"http_error": "HTTP_ERR",
"connection_error": "NET_ERR",
"upstream_error_event": "UP_ERR",
"text_progressive": "T_STREAM",
"text_missing": "T_NONE",
"text_single_or_burst": "T_BUFFER",
"forced_tool_ignored": "W_IGNORED",
"tool_arguments_missing": "W_NONE",
"tool_multiple_calls": "W_MULTI",
"tool_single_block": "W_ONE",
"tool_buffered_burst": "W_BURST",
"tool_progressive_invalid_json": "W_BADJSON",
"tool_progressive": "W_STREAM",
}
class _Tee:
"""Write console output to both the terminal and the diagnostic log."""
def __init__(self, *streams: TextIO) -> None:
self._streams = streams
def write(self, value: str) -> int:
for stream in self._streams:
stream.write(value)
stream.flush()
return len(value)
def flush(self) -> None:
for stream in self._streams:
stream.flush()
def isatty(self) -> bool:
return any(
getattr(stream, "isatty", lambda: False)() for stream in self._streams
)
def _chat_completions_url(value: str) -> str:
raw_url = value.strip()
if not raw_url:
raise ValueError("DF_LLM_URL/--url is required")
parsed = urlsplit(raw_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("DF_LLM_URL/--url must be an absolute http(s) URL")
path = parsed.path.rstrip("/")
if not path.endswith("/chat/completions"):
path = f"{path}/chat/completions"
return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, ""))
def _safe_url_for_log(value: str) -> str:
"""Remove credentials, query parameters, and fragments from log output."""
parsed = urlsplit(value)
hostname = parsed.hostname or ""
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]"
netloc = hostname
if parsed.port is not None:
netloc = f"{netloc}:{parsed.port}"
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
def _sse_events(response: Any) -> Iterator[tuple[str, str]]:
event_name = "message"
data_lines: list[str] = []
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
if not line:
if data_lines:
yield event_name, "\n".join(data_lines)
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
event_name = line[6:].strip() or "message"
elif line.startswith("data:"):
data_lines.append(line[5:].lstrip())
if data_lines:
yield event_name, "\n".join(data_lines)
def _choices(payload: dict[str, Any]) -> list[dict[str, Any]]:
value = payload.get("choices")
if not isinstance(value, list):
nested = payload.get("chunk")
value = nested.get("choices") if isinstance(nested, dict) else None
return [item for item in value or [] if isinstance(item, dict)]
def _argument_text(value: Any) -> tuple[str, bool]:
if isinstance(value, str):
return value, False
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":")), True
return "", False
@dataclass
class ProbeStats:
started_at: float
response_opened_at: float | None = None
finished_at: float | None = None
first_event_at: float | None = None
first_tool_at: float | None = None
last_tool_at: float | None = None
first_text_at: float | None = None
last_text_at: float | None = None
events: int = 0
tool_chunks: int = 0
text_chunks: int = 0
structured_tool_chunks: int = 0
argument_updates: int = 0
first_argument_part_chars: int = 0
last_argument_part_chars: int = 0
max_argument_part_chars: int = 0
malformed_events: int = 0
application_error_events: int = 0
finish_reasons: list[str] = field(default_factory=list)
arguments: str = ""
arguments_by_call: dict[str, str] = field(default_factory=dict)
first_argument_update_at: float | None = None
last_argument_update_at: float | None = None
def elapsed(self, instant: float | None) -> float:
return -1.0 if instant is None else instant - self.started_at
def add_arguments(
self,
part: str,
*,
call_key: str,
structured: bool,
now: float,
) -> tuple[str, float]:
previous_tool_at = self.last_tool_at
self.tool_chunks += 1
if self.first_argument_part_chars == 0:
self.first_argument_part_chars = len(part)
self.last_argument_part_chars = len(part)
self.max_argument_part_chars = max(self.max_argument_part_chars, len(part))
if structured:
self.structured_tool_chunks += 1
if self.first_tool_at is None:
self.first_tool_at = now
self.last_tool_at = now
previous = self.arguments_by_call.get(call_key, "")
if not previous:
style = "initial"
assembled = part
elif part.startswith(previous):
style = "snapshot"
assembled = part
elif previous.startswith(part):
style = "repeat/rewind"
assembled = previous
else:
style = "delta"
assembled = previous + part
self.arguments_by_call[call_key] = assembled
self.arguments = max(self.arguments_by_call.values(), key=len, default="")
if len(assembled) > len(previous):
self.argument_updates += 1
if self.first_argument_update_at is None:
self.first_argument_update_at = now
self.last_argument_update_at = now
gap_ms = -1.0 if previous_tool_at is None else (now - previous_tool_at) * 1000
return style, gap_ms
def _tool_call_key(tool_call: dict[str, Any], stats: ProbeStats) -> str:
index = tool_call.get("index")
if isinstance(index, int):
return f"index:{index}"
call_id = tool_call.get("id")
if isinstance(call_id, str) and call_id:
return f"id:{call_id}"
if len(stats.arguments_by_call) == 1:
return next(iter(stats.arguments_by_call))
return "default"
def _observe_payload(stats: ProbeStats, event: dict[str, Any], now: float) -> None:
if event.get("error"):
stats.application_error_events += 1
print(
f"UPSTREAM_ERROR_EVENT t={stats.elapsed(now):.3f}s "
f"error_type={type(event.get('error')).__name__} content=redacted"
)
for choice in _choices(event):
reason = choice.get("finish_reason")
if isinstance(reason, str) and reason:
stats.finish_reasons.append(reason)
print(f"FINISH t={stats.elapsed(now):.3f}s reason={reason}")
delta = choice.get("delta")
if not isinstance(delta, dict):
message = choice.get("message")
delta = message if isinstance(message, dict) else {}
content = delta.get("content")
if isinstance(content, str) and content:
previous_text_at = stats.last_text_at
stats.text_chunks += 1
if stats.first_text_at is None:
stats.first_text_at = now
stats.last_text_at = now
gap_ms = (
-1.0 if previous_text_at is None else (now - previous_text_at) * 1000
)
print(
f"TEXT #{stats.text_chunks:04d} "
f"t={stats.elapsed(now):8.3f}s gap_ms={gap_ms:8.1f} "
f"chars={len(content):5d}"
)
tool_calls = delta.get("tool_calls")
if isinstance(tool_calls, dict):
tool_calls = [tool_calls]
if not isinstance(tool_calls, list):
continue
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
function = tool_call.get("function")
if not isinstance(function, dict) or "arguments" not in function:
continue
part, structured = _argument_text(function.get("arguments"))
if not part:
continue
style, gap_ms = stats.add_arguments(
part,
call_key=_tool_call_key(tool_call, stats),
structured=structured,
now=now,
)
print(
f"TOOL #{stats.tool_chunks:04d} "
f"t={stats.elapsed(now):8.3f}s gap_ms={gap_ms:8.1f} "
f"chars={len(part):5d} "
f"assembled={len(stats.arguments):6d} style={style}"
)
def _safe_response_headers(response: Any) -> dict[str, str]:
allowed = (
"Content-Type",
"Transfer-Encoding",
"Content-Encoding",
"Cache-Control",
"X-Accel-Buffering",
"Connection",
"Server",
"Via",
"X-Request-Id",
"Date",
)
result: dict[str, str] = {}
for name in allowed:
value = response.headers.get(name)
if isinstance(value, str) and value:
result[name.lower()] = value[:300]
version = getattr(response, "version", None)
if version in {10, 11}:
result["http_version"] = "1.0" if version == 10 else "1.1"
elif version is not None:
result["http_version"] = str(version)[:30]
return result
def _emit_result(
*,
args: argparse.Namespace,
mode: str,
outcome: str,
verdict: str,
exit_code: int,
metrics: dict[str, Any],
) -> int:
print(f"VERDICT: {verdict}")
record = {
"record_type": "model_stream_probe_result",
"script_version": SCRIPT_VERSION,
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"mode": mode,
"outcome": outcome,
"exit_code": exit_code,
"endpoint": _safe_url_for_log(_chat_completions_url(args.url)),
"model": args.model,
**metrics,
}
print(
"RESULT_JSON="
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
)
response_headers = metrics.get("response_headers")
headers = response_headers if isinstance(response_headers, dict) else {}
content_type = str(headers.get("content-type", "")).lower()
finish_reasons = metrics.get("finish_reasons")
finish = (
finish_reasons[-1]
if isinstance(finish_reasons, list) and finish_reasons
else "none"
)
def compact(value: Any, default: str = "-") -> str:
if value is None or value == "":
return default
if isinstance(value, bool):
return "1" if value else "0"
text = str(value)
return "".join(
char if char.isalnum() or char in "._:/-" else "_" for char in text
)[:40]
short_fields = {
"cv": "1",
"sv": SCRIPT_VERSION,
"md": args.model,
"m": mode,
"c": _SHORT_OUTCOME_CODES.get(outcome, outcome),
"h": metrics.get("http_status"),
"sse": "1" if "text/event-stream" in content_type else "0",
"txt": metrics.get("text_chunks"),
"ts": metrics.get("text_span_ms"),
"tc": metrics.get("tool_arg_chunks"),
"au": metrics.get("argument_updates"),
"fa": metrics.get("first_argument_part_chars"),
"aa": metrics.get("assembled_argument_chars"),
"ft": metrics.get("first_tool_arg_ms"),
"us": metrics.get("argument_update_span_ms"),
"pw": metrics.get("preview_window_ms"),
"tt": metrics.get("total_ms"),
"js": metrics.get("valid_final_json"),
"ce": headers.get("content-encoding", "none"),
"te": headers.get("transfer-encoding", "none"),
"xb": headers.get("x-accel-buffering", "none"),
"fr": finish,
"ev": metrics.get("events"),
"bad": metrics.get("malformed_events"),
"ae": metrics.get("application_error_events"),
"et": metrics.get("error_type"),
"rt": metrics.get("reason_type"),
}
print(
"COPY_THIS "
+ " ".join(f"{key}={compact(value)}" for key, value in short_fields.items())
)
return exit_code
def _tool_schema() -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": "write_file",
"description": "Write complete text content to a file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
},
}
def _request_payload(args: argparse.Namespace, mode: str) -> dict[str, Any]:
if mode == "tool":
user_prompt = (
"只调用一次 write_file,不要输出普通正文。将 Markdown 写到 "
"/mnt/user-data/outputs/model-stream-probe.md。Markdown 第一行为标题,"
f"随后生成 {args.lines} 行有实际内容的编号测试句;每行至少 30 个中文字符,"
"行与行内容不要完全相同。必须把全部 Markdown 放在 content 参数中。"
)
payload: dict[str, Any] = {
"model": args.model,
"messages": [
{"role": "system", "content": "严格遵循工具调用要求。"},
{"role": "user", "content": user_prompt},
],
"stream": True,
"temperature": 0,
"max_tokens": args.max_tokens,
"tools": [_tool_schema()],
"tool_choice": {
"type": "function",
"function": {"name": "write_file"},
},
}
else:
payload = {
"model": args.model,
"messages": [
{
"role": "user",
"content": (
f"逐行输出 {args.lines} 行编号测试句,每行至少 30 个中文字符。"
"不要调用工具,不要使用代码块。"
),
}
],
"stream": True,
"temperature": 0,
"max_tokens": args.max_tokens,
}
return payload
def _run_probe(args: argparse.Namespace, mode: str) -> int:
url = _chat_completions_url(args.url)
body = json.dumps(
_request_payload(args, mode), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
headers = {
"Accept": "text/event-stream",
"Accept-Encoding": "identity",
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
"User-Agent": "deerflow-tool-stream-probe/1.0",
}
api_key = os.environ.get("DF_LLM_KEY", "").strip()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = Request(url=url, data=body, headers=headers, method="POST")
context = (
ssl._create_unverified_context()
if args.insecure
else ssl.create_default_context()
)
stats = ProbeStats(started_at=time.monotonic())
response_headers: dict[str, str] = {}
http_status: int | str = "unknown"
print(f"\n=== {mode.upper()} probe ===")
print(f"endpoint={_safe_url_for_log(url)}")
print(f"model={args.model} lines={args.lines} max_tokens={args.max_tokens}")
try:
with urlopen(request, timeout=args.timeout, context=context) as response:
stats.response_opened_at = time.monotonic()
content_type = response.headers.get("Content-Type", "unknown")
http_status = getattr(response, "status", "unknown")
response_headers = _safe_response_headers(response)
response_open_ms = stats.elapsed(stats.response_opened_at) * 1000
print(
f"http_status={http_status} content_type={content_type} "
f"response_open_ms={response_open_ms:.0f}"
)
print(
"response_headers="
+ json.dumps(
response_headers,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
)
if "text/event-stream" not in content_type.lower():
raw_payload = response.read(MAX_NON_SSE_BYTES + 1)
now = time.monotonic()
stats.events = 1
stats.first_event_at = now
if len(raw_payload) > MAX_NON_SSE_BYTES:
print("NON_SSE_RESPONSE_TOO_LARGE bytes_gt=67108864")
stats.malformed_events += 1
else:
try:
event = json.loads(
raw_payload.decode("utf-8", errors="replace")
)
except (UnicodeDecodeError, json.JSONDecodeError):
stats.malformed_events += 1
print(f"NON_JSON_RESPONSE chars={len(raw_payload)}")
else:
if isinstance(event, dict):
print("NON_SSE_JSON_RESPONSE buffered=true")
_observe_payload(stats, event, now)
else:
stats.malformed_events += 1
else:
for event_name, data in _sse_events(response):
now = time.monotonic()
stats.events += 1
if stats.first_event_at is None:
stats.first_event_at = now
if data.strip() == "[DONE]":
print(f"DONE t={stats.elapsed(now):.3f}s event={event_name}")
break
try:
event = json.loads(data)
except json.JSONDecodeError:
stats.malformed_events += 1
print(
f"NON_JSON t={stats.elapsed(now):.3f}s "
f"event={event_name} chars={len(data)}"
)
continue
if isinstance(event, dict):
_observe_payload(stats, event, now)
except HTTPError as exc:
error_body = exc.read(2000).decode("utf-8", errors="replace")
print(f"HTTP_ERROR status={exc.code} reason={exc.reason}", file=sys.stderr)
if error_body:
print(
f"HTTP_ERROR_BODY_REDACTED chars={len(error_body)}",
file=sys.stderr,
)
return _emit_result(
args=args,
mode=mode,
outcome="http_error",
verdict="The upstream endpoint rejected the request; inspect the HTTP status and model/tool compatibility.",
exit_code=2,
metrics={
"http_status": exc.code,
"http_error_body_chars": len(error_body),
},
)
except (URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", None)
reason_type = type(reason).__name__ if reason is not None else "none"
print(
f"CONNECT_ERROR type={type(exc).__name__} reason_type={reason_type}",
file=sys.stderr,
)
return _emit_result(
args=args,
mode=mode,
outcome="connection_error",
verdict="The direct upstream request failed before a complete response was observed.",
exit_code=2,
metrics={
"error_type": type(exc).__name__,
"reason_type": reason_type,
},
)
finally:
stats.finished_at = time.monotonic()
tool_span = (
stats.last_tool_at - stats.first_tool_at
if stats.first_tool_at is not None and stats.last_tool_at is not None
else 0.0
)
update_span = (
stats.last_argument_update_at - stats.first_argument_update_at
if stats.first_argument_update_at is not None
and stats.last_argument_update_at is not None
else 0.0
)
text_span = (
stats.last_text_at - stats.first_text_at
if stats.first_text_at is not None and stats.last_text_at is not None
else 0.0
)
valid_json = False
if stats.arguments:
try:
parsed = json.loads(stats.arguments)
valid_json = isinstance(parsed, dict)
except (TypeError, ValueError):
pass
print("--- summary ---")
print(
"events={} malformed={} tool_chunks={} tool_span={:.3f}s "
"argument_updates={} update_span={:.3f}s tool_calls={} "
"assembled_chars={} valid_final_json={} structured_tool_chunks={} "
"text_chunks={} text_span={:.3f}s total={:.3f}s finish={}".format(
stats.events,
stats.malformed_events,
stats.tool_chunks,
tool_span,
stats.argument_updates,
update_span,
len(stats.arguments_by_call),
len(stats.arguments),
str(valid_json).lower(),
stats.structured_tool_chunks,
stats.text_chunks,
text_span,
stats.elapsed(stats.finished_at),
",".join(stats.finish_reasons) or "none",
)
)
def elapsed_ms(instant: float | None) -> int | None:
return round(stats.elapsed(instant) * 1000) if instant is not None else None
metrics: dict[str, Any] = {
"http_status": http_status,
"response_headers": response_headers,
"response_open_ms": elapsed_ms(stats.response_opened_at),
"first_event_ms": elapsed_ms(stats.first_event_at),
"first_tool_arg_ms": elapsed_ms(stats.first_tool_at),
"last_tool_arg_ms": elapsed_ms(stats.last_tool_at),
"tool_arg_span_ms": round(tool_span * 1000),
"argument_update_span_ms": round(update_span * 1000),
"preview_window_ms": (
round((stats.finished_at - stats.first_tool_at) * 1000)
if stats.finished_at is not None and stats.first_tool_at is not None
else -1
),
"first_text_ms": elapsed_ms(stats.first_text_at),
"text_span_ms": round(text_span * 1000),
"total_ms": round(stats.elapsed(stats.finished_at) * 1000),
"events": stats.events,
"malformed_events": stats.malformed_events,
"application_error_events": stats.application_error_events,
"tool_arg_chunks": stats.tool_chunks,
"argument_updates": stats.argument_updates,
"first_argument_part_chars": stats.first_argument_part_chars,
"last_argument_part_chars": stats.last_argument_part_chars,
"max_argument_part_chars": stats.max_argument_part_chars,
"tool_calls": len(stats.arguments_by_call),
"assembled_argument_chars": len(stats.arguments),
"valid_final_json": valid_json,
"structured_tool_chunks": stats.structured_tool_chunks,
"text_chunks": stats.text_chunks,
"finish_reasons": stats.finish_reasons,
}
if stats.application_error_events:
return _emit_result(
args=args,
mode=mode,
outcome="upstream_error_event",
verdict="The HTTP stream contained an upstream error event; its content was redacted from this log.",
exit_code=2,
metrics=metrics,
)
if mode == "text":
if stats.text_chunks > 1 and text_span >= args.live_span:
return _emit_result(
args=args,
mode=mode,
outcome="text_progressive",
verdict="Ordinary text is genuinely streaming from the upstream endpoint.",
exit_code=0,
metrics=metrics,
)
elif stats.text_chunks == 0:
return _emit_result(
args=args,
mode=mode,
outcome="text_missing",
verdict="No ordinary text deltas were observed.",
exit_code=1,
metrics=metrics,
)
else:
return _emit_result(
args=args,
mode=mode,
outcome="text_single_or_burst",
verdict="Text arrived in one chunk or a short burst; upstream/proxy buffering is possible.",
exit_code=1,
metrics=metrics,
)
if stats.tool_chunks == 0:
if stats.text_chunks:
verdict = (
"The upstream returned ordinary text instead of the forced tool call."
)
outcome = "forced_tool_ignored"
else:
verdict = "No tool-argument chunks were observed; inspect the raw model gateway response."
outcome = "tool_arguments_missing"
return _emit_result(
args=args,
mode=mode,
outcome=outcome,
verdict=verdict,
exit_code=1,
metrics=metrics,
)
if len(stats.arguments_by_call) != 1:
return _emit_result(
args=args,
mode=mode,
outcome="tool_multiple_calls",
verdict=(
"The endpoint produced multiple write_file calls even though exactly one was "
"requested. This run is not valid for measuring one call's progressive arguments."
),
exit_code=1,
metrics=metrics,
)
if stats.argument_updates <= 1:
return _emit_result(
args=args,
mode=mode,
outcome="tool_single_block",
verdict=(
"Tool arguments arrived as one complete block (or that block was merely repeated). "
"The model-serving/OpenAI-compatible gateway is not progressively streaming this "
"tool call; DeerFlow was bypassed."
),
exit_code=1,
metrics=metrics,
)
if update_span < args.live_span:
return _emit_result(
args=args,
mode=mode,
outcome="tool_buffered_burst",
verdict=(
"Multiple growing tool chunks arrived only as a short burst. A model gateway or "
"HTTP proxy may be buffering; this is not proof of live tool streaming."
),
exit_code=1,
metrics=metrics,
)
if not valid_json:
return _emit_result(
args=args,
mode=mode,
outcome="tool_progressive_invalid_json",
verdict=(
"Tool arguments streamed progressively, but the final assembled arguments were not "
"valid JSON. Check model output truncation and max_tokens before testing the UI."
),
exit_code=1,
metrics=metrics,
)
return _emit_result(
args=args,
mode=mode,
outcome="tool_progressive",
verdict=(
"Tool arguments arrived in multiple time-separated growing chunks. The upstream endpoint "
"can stream write_file arguments; if DeerFlow still updates only at the end, investigate "
"the backend SSE/proxy/frontend path."
),
exit_code=0,
metrics=metrics,
)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Directly test OpenAI-compatible text/tool-call streaming."
)
parser.add_argument("--url", default=os.environ.get("DF_LLM_URL", ""))
parser.add_argument("--model", default=os.environ.get("DF_LLM_MODEL", ""))
parser.add_argument("--mode", choices=("tool", "text", "both"), default="both")
parser.add_argument("--lines", type=int, default=80)
parser.add_argument("--max-tokens", type=int, default=8192)
parser.add_argument("--timeout", type=float, default=600.0)
parser.add_argument(
"--log-file",
default=os.environ.get("DF_LLM_LOG_FILE", ""),
help=(
"Diagnostic log path. Defaults to model-tool-stream-probe-<timestamp>.log "
"in the current directory."
),
)
parser.add_argument(
"--live-span",
type=float,
default=0.20,
help="Minimum first-to-last chunk span considered genuinely live (seconds).",
)
parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS certificate verification for an internal self-signed endpoint.",
)
args = parser.parse_args()
if not args.model.strip():
parser.error("DF_LLM_MODEL/--model is required")
if not args.url.strip():
parser.error("DF_LLM_URL/--url is required")
if args.lines < 1 or args.max_tokens < 1 or args.timeout <= 0 or args.live_span < 0:
parser.error("numeric arguments must be positive")
try:
_chat_completions_url(args.url)
except ValueError as exc:
parser.error(str(exc))
if not args.log_file.strip():
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
args.log_file = f"model-tool-stream-probe-{timestamp}.log"
return args
def main() -> int:
args = _parse_args()
log_path = Path(args.log_file).expanduser().resolve()
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
log_stream = log_path.open("a", encoding="utf-8", buffering=1)
except OSError as exc:
print(
f"LOG_OPEN_ERROR path={log_path} type={type(exc).__name__}",
file=sys.stderr,
)
return 2
console_out = sys.stdout
console_err = sys.stderr
with (
log_stream,
redirect_stdout(_Tee(console_out, log_stream)),
redirect_stderr(_Tee(console_err, log_stream)),
):
started_local = datetime.now().astimezone()
print("=" * 78)
print("DEERFLOW DIRECT MODEL STREAM PROBE")
print(f"script_version={SCRIPT_VERSION}")
print(f"started_at_local={started_local.isoformat()}")
print(f"started_at_utc={started_local.astimezone(timezone.utc).isoformat()}")
print(
f"python={platform.python_version()} implementation={platform.python_implementation()}"
)
print(f"platform={platform.platform()}")
print(f"log_file={log_path}")
print(f"endpoint={_safe_url_for_log(_chat_completions_url(args.url))}")
print(f"model={args.model}")
print(
f"mode={args.mode} lines={args.lines} max_tokens={args.max_tokens} "
f"timeout={args.timeout:.1f}s live_span={args.live_span:.3f}s"
)
print(
f"api_key_present={str(bool(os.environ.get('DF_LLM_KEY', '').strip())).lower()} "
f"tls_verification={str(not args.insecure).lower()}"
)
print(
"privacy=API key, prompts, generated text, and tool-argument content are not logged"
)
print("=" * 78)
modes = ("text", "tool") if args.mode == "both" else (args.mode,)
try:
results = [_run_probe(args, mode) for mode in modes]
exit_code = max(results, default=0)
except KeyboardInterrupt:
print("PROBE_INTERRUPTED by_user=true", file=sys.stderr)
exit_code = 130
except Exception as exc: # noqa: BLE001 - top-level probe must leave a redacted log
print(
f"UNEXPECTED_PROBE_ERROR type={type(exc).__name__} details=redacted",
file=sys.stderr,
)
exit_code = 3
print("=" * 78)
print(f"finished_at_local={datetime.now().astimezone().isoformat()}")
print(f"overall_exit_code={exit_code}")
print(f"log_file={log_path}")
print(
"If you cannot send the log file, type only the two COPY_THIS lines above."
)
print("=" * 78)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())