#!/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-.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())