Files
opensquilla--opensquilla/scripts/live_router_provider_output_recovery.py
2026-07-13 13:12:33 +08:00

538 lines
19 KiB
Python

#!/usr/bin/env python3
"""Live router-enabled provider output recovery evidence.
Opt-in maintainer script. It uses OpenRouter and temporary OpenSquilla state to
capture evidence for two provider-output failure modes:
* provider output stopped by the length cap
* large-input reasoning-only responses with no visible text
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = REPO_ROOT / "src"
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from opensquilla.env import load_env # noqa: E402
from scripts.smoke_v4_phase3_router import ( # noqa: E402
_live_tier_model_map,
_read_turn_call_records,
_write_live_gateway_config,
)
def _read_jsonl_records(log_dir: Path, prefix: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for path in sorted(log_dir.glob(f"{prefix}-*.jsonl")):
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
return rows
def _turn_records(records: list[dict[str, Any]], session_key: str) -> list[dict[str, Any]]:
return [record for record in records if record.get("session_key") == session_key]
def _response_usage(record: dict[str, Any]) -> dict[str, Any]:
return ((record.get("payload") or {}).get("usage") or {})
def _finish_reasons(records: list[dict[str, Any]]) -> list[str | None]:
reasons: list[str | None] = []
for record in records:
if record.get("kind") == "llm_response":
usage = _response_usage(record)
reasons.append(usage.get("stop_reason"))
return reasons
def _llm_request_configs(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
configs: list[dict[str, Any]] = []
for record in records:
if record.get("kind") != "llm_request":
continue
payload = record.get("payload") or {}
config = payload.get("config") or {}
configs.append(config)
return configs
def _decision_steps(decisions: list[dict[str, Any]], session_key: str) -> list[dict[str, Any]]:
for row in reversed(decisions):
if row.get("session_key") == session_key:
steps = row.get("pipeline_steps")
return steps if isinstance(steps, list) else []
return []
def _router_step(decisions: list[dict[str, Any]], session_key: str) -> dict[str, Any]:
for step in _decision_steps(decisions, session_key):
if isinstance(step, dict) and step.get("step_name") == "squilla_router":
return step
return {}
def _classify_direct_response(data: dict[str, Any]) -> dict[str, Any]:
choices = data.get("choices") if isinstance(data, dict) else None
choice = choices[0] if isinstance(choices, list) and choices else {}
message = choice.get("message") if isinstance(choice, dict) else {}
if not isinstance(message, dict):
message = {}
content = message.get("content")
reasoning_content = message.get("reasoning_content") or message.get("reasoning")
usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
completion_details = usage.get("completion_tokens_details")
if not isinstance(completion_details, dict):
completion_details = {}
reasoning_tokens = int(completion_details.get("reasoning_tokens") or 0)
visible = content if isinstance(content, str) else ""
reasoning = reasoning_content if isinstance(reasoning_content, str) else ""
finish_reason = choice.get("finish_reason") if isinstance(choice, dict) else None
if finish_reason == "length":
kind = "length_capped"
elif visible.strip():
kind = "ok"
elif reasoning.strip() or reasoning_tokens > 0:
kind = "reasoning_only"
else:
kind = "malformed_empty"
return {
"kind": kind,
"finish_reason": finish_reason,
"content_len": len(visible),
"reasoning_len": len(reasoning),
"usage": usage,
}
def _openrouter_chat(
*,
model: str,
message: str,
max_tokens: int,
thinking: bool,
timeout_seconds: float,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": message}],
"max_tokens": max_tokens,
"max_completion_tokens": max_tokens,
"temperature": 0,
}
if thinking:
payload["reasoning"] = {"effort": "high"}
else:
payload["reasoning"] = {"enabled": False}
request = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"content-type": "application/json",
"http-referer": "https://github.com/opensquilla/opensquilla",
"x-title": "OpenSquilla provider output recovery live evidence",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
return json.loads(response.read().decode("utf-8"))
def _length_prompt() -> str:
return (
"Do not call tools. Answer directly in plain text. Write exactly 18 "
"numbered lines. Each line must be one complete sentence with marker "
"PROVIDER_OUTPUT_RECOVERY_LENGTH and at least 14 words. Do not summarize "
"and do not stop early."
)
def _large_reasoning_prompt(chars: int) -> str:
line = (
"large-context-provider-output-recovery marker data: "
"alpha beta gamma delta epsilon zeta eta theta iota kappa.\n"
)
filler = (line * max(1, chars // len(line) + 1))[:chars]
return (
"Read the following large material. Reply with exactly one short visible "
"sentence containing marker LARGE_REASONING_VISIBLE. Do not call tools.\n\n"
f"{filler}\n\n"
"Final instruction: output only the visible sentence now."
)
def _router_sanity_prompt() -> str:
return "Compare PostgreSQL and MySQL replication tradeoffs in three concise bullets."
def _write_config(
path: Path,
*,
live_model: str,
max_tokens: int,
) -> None:
_write_live_gateway_config(path, live_model)
text = path.read_text(encoding="utf-8")
text = text.replace("max_tokens = 192", f"max_tokens = {max_tokens}", 1)
path.write_text(text, encoding="utf-8")
def _runtime_router_available() -> dict[str, Any]:
try:
from opensquilla.squilla_router.v4_phase3 import V4Phase3Strategy
V4Phase3Strategy(require_router_runtime=True)
return {"ok": True}
except Exception as exc: # noqa: BLE001 - preflight report
return {"ok": False, "error": str(exc)}
def _run_cli_case(
*,
tmp_path: Path,
case_id: str,
message: str,
live_model: str,
max_tokens: int,
timeout_seconds: float,
length_capped_continuations: int | None,
max_provider_retries: int = 3,
thinking: str | None = None,
) -> dict[str, Any]:
config_path = tmp_path / f"{case_id}-config.toml"
log_dir = tmp_path / f"{case_id}-logs"
turn_log_dir = tmp_path / f"{case_id}-turn-calls"
state_dir = tmp_path / f"{case_id}-state"
workspace = tmp_path / f"{case_id}-workspace"
scratch = tmp_path / f"{case_id}-scratch"
session_db = tmp_path / f"{case_id}-sessions.sqlite"
_write_config(config_path, live_model=live_model, max_tokens=max_tokens)
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC_DIR) + os.pathsep + env.get("PYTHONPATH", "")
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(config_path)
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
env["OPENSQUILLA_LOG_DIR"] = str(log_dir)
env["OPENSQUILLA_MEMORY_DREAM_DISABLED"] = "1"
env["OPENSQUILLA_TURN_CALL_LOG"] = "1"
env["OPENSQUILLA_TURN_CALL_LOG_DIR"] = str(turn_log_dir)
env["OPENSQUILLA_SANDBOX_SANDBOX"] = "false"
env["OPENSQUILLA_SANDBOX_SECURITY_GRADING"] = "false"
env["OPENSQUILLA_TOOL_PROFILE"] = "channel_default"
env.pop("OPENSQUILLA_LLM_THINKING", None)
session_id = f"live-provider-output-{case_id}-{int(time.time() * 1000)}"
cmd = [
sys.executable,
"-m",
"opensquilla.cli.main",
"agent",
"--message",
message,
"--json",
"--session-id",
session_id,
"--session-db-path",
str(session_db),
"--workspace",
str(workspace),
"--scratch-dir",
str(scratch),
"--permissions",
"restricted",
"--no-memory-capture",
"--timeout",
str(int(timeout_seconds)),
"--request-timeout-seconds",
str(int(timeout_seconds)),
"--max-provider-retries",
str(max_provider_retries),
]
if length_capped_continuations is not None:
cmd.extend(["--length-capped-continuations", str(length_capped_continuations)])
if thinking:
cmd.extend(["--thinking", thinking])
proc = subprocess.run(
cmd,
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
timeout=timeout_seconds + 30,
check=False,
)
stdout = proc.stdout.strip()
result: dict[str, Any]
try:
result = json.loads(stdout.splitlines()[-1]) if stdout else {}
except json.JSONDecodeError:
result = {"raw_stdout_tail": stdout[-2000:]}
session_key = str(result.get("session_key") or f"agent:main:{session_id}")
turn_records = _turn_records(_read_turn_call_records(turn_log_dir), session_key)
decisions = _read_jsonl_records(log_dir, "decisions")
return {
"case_id": case_id,
"session_key": session_key,
"returncode": proc.returncode,
"result": result,
"stdout_tail": proc.stdout[-2000:],
"stderr_tail": proc.stderr[-4000:],
"turn_records": turn_records,
"decisions": [row for row in decisions if row.get("session_key") == session_key],
"router_step": _router_step(decisions, session_key),
"request_configs": _llm_request_configs(turn_records),
"finish_reasons": _finish_reasons(turn_records),
"llm_response_count": sum(1 for r in turn_records if r.get("kind") == "llm_response"),
"llm_request_count": sum(1 for r in turn_records if r.get("kind") == "llm_request"),
}
def _summarize_case(raw: dict[str, Any]) -> dict[str, Any]:
result = raw.get("result") if isinstance(raw.get("result"), dict) else {}
errors = result.get("errors") if isinstance(result.get("errors"), list) else []
router_step = raw.get("router_step") if isinstance(raw.get("router_step"), dict) else {}
request_configs = raw.get("request_configs") or []
return {
"case_id": raw.get("case_id"),
"session_key": raw.get("session_key"),
"returncode": raw.get("returncode"),
"status": result.get("status"),
"text_len": len(str(result.get("text") or "")),
"errors": errors,
"routing": result.get("routing"),
"router_step": router_step,
"request_models": [
record.get("model")
for record in raw.get("turn_records", [])
if record.get("kind") == "llm_request"
],
"request_thinking": [config.get("thinking") for config in request_configs],
"request_thinking_levels": [config.get("thinking_level") for config in request_configs],
"finish_reasons": raw.get("finish_reasons"),
"llm_request_count": raw.get("llm_request_count"),
"llm_response_count": raw.get("llm_response_count"),
"stderr_tail": str(raw.get("stderr_tail") or "")[-1200:],
}
def _error_codes(summary: dict[str, Any]) -> set[str]:
return {
str(error.get("code"))
for error in summary.get("errors", [])
if isinstance(error, dict) and error.get("code")
}
def run_live(
*,
mode: str,
timeout_seconds: float,
length_capped_continuations: int,
large_chars: int,
max_tokens: int,
large_max_tokens: int,
reasoning_model: str,
) -> dict[str, Any]:
load_env(REPO_ROOT)
if not os.environ.get("OPENROUTER_API_KEY"):
return {"ok": False, "error": "OPENROUTER_API_KEY is required"}
router_runtime = _runtime_router_available()
if not router_runtime.get("ok"):
return {
"ok": False,
"error": "router runtime unavailable",
"router_runtime": router_runtime,
"hint": "Run `uv sync --extra dev --extra recommended` before live router checks.",
}
tier_models = _live_tier_model_map("")
direct_length: dict[str, Any]
direct_reasoning: dict[str, Any]
try:
direct_length = _classify_direct_response(
_openrouter_chat(
model=tier_models["c1"],
message=_length_prompt(),
max_tokens=min(max_tokens, 96),
thinking=False,
timeout_seconds=timeout_seconds,
)
)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
direct_length = {"kind": "api_error", "error": str(exc)}
try:
direct_reasoning = _classify_direct_response(
_openrouter_chat(
model=reasoning_model,
message=_large_reasoning_prompt(large_chars),
max_tokens=large_max_tokens,
thinking=True,
timeout_seconds=timeout_seconds,
)
)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
direct_reasoning = {"kind": "api_error", "error": str(exc)}
with tempfile.TemporaryDirectory(
prefix="opensquilla-live-provider-output-",
ignore_cleanup_errors=True,
) as tmp:
tmp_path = Path(tmp)
sanity_raw = _run_cli_case(
tmp_path=tmp_path,
case_id="router-sanity",
message=_router_sanity_prompt(),
live_model="",
max_tokens=max_tokens,
timeout_seconds=timeout_seconds,
length_capped_continuations=length_capped_continuations,
)
truncation_raw = _run_cli_case(
tmp_path=tmp_path,
case_id=f"truncation-{mode}",
message=_length_prompt(),
live_model="",
max_tokens=max_tokens,
timeout_seconds=timeout_seconds,
length_capped_continuations=length_capped_continuations,
thinking="off",
)
large_raw: dict[str, Any] | None = None
if direct_reasoning.get("kind") == "reasoning_only":
large_raw = _run_cli_case(
tmp_path=tmp_path,
case_id=f"large-reasoning-{mode}",
message=_large_reasoning_prompt(large_chars),
live_model=reasoning_model,
max_tokens=large_max_tokens,
timeout_seconds=timeout_seconds,
length_capped_continuations=length_capped_continuations,
max_provider_retries=1,
)
sanity = _summarize_case(sanity_raw)
truncation = _summarize_case(truncation_raw)
large_summary = _summarize_case(large_raw) if large_raw is not None else None
sanity_routing = sanity.get("routing") if isinstance(sanity.get("routing"), dict) else {}
sanity_ok = (
sanity.get("returncode") == 0
and sanity.get("status") == "ok"
and (
(sanity.get("router_step") or {}).get("routing_source") == "v4_phase3"
or sanity_routing.get("routing_source") == "v4_phase3"
)
)
truncation_errors = _error_codes(truncation)
saw_length = "length" in (truncation.get("finish_reasons") or [])
if mode == "reproduce":
truncation_ok = saw_length and "provider_output_truncated" in truncation_errors
else:
truncation_ok = (
saw_length
and truncation.get("status") == "ok"
and "provider_output_truncated" not in truncation_errors
and int(truncation.get("llm_request_count") or 0) >= 2
)
large_ok = True
large_note = "skipped_provider_drift"
if large_summary is not None:
thinking_values = large_summary.get("request_thinking") or []
large_errors = _error_codes(large_summary)
large_ok = (
len(thinking_values) >= 2
and thinking_values[0] is True
and False in thinking_values[1:]
and large_summary.get("status") == "ok"
and "empty_response" not in large_errors
)
large_note = "verified_recovery"
return {
"ok": sanity_ok and truncation_ok and large_ok,
"mode": mode,
"length_capped_continuations": length_capped_continuations,
"max_tokens": max_tokens,
"large_max_tokens": large_max_tokens,
"router_runtime": router_runtime,
"tier_models": tier_models,
"direct_calibration": {
"length": direct_length,
"large_reasoning": direct_reasoning,
},
"router_sanity": {
"ok": sanity_ok,
**sanity,
},
"truncation_case": {
"ok": truncation_ok,
**truncation,
},
"large_reasoning_case": {
"ok": large_ok,
"note": large_note,
"direct_kind": direct_reasoning.get("kind"),
**(large_summary or {}),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=["reproduce", "verify"], default="verify")
parser.add_argument("--json", action="store_true", help="Emit JSON report")
parser.add_argument("--timeout-seconds", type=float, default=180.0)
parser.add_argument("--length-capped-continuations", type=int, default=None)
parser.add_argument("--large-chars", type=int, default=140_000)
parser.add_argument("--max-tokens", type=int, default=192)
parser.add_argument("--large-max-tokens", type=int, default=1024)
parser.add_argument("--reasoning-model", default="z-ai/glm-5.1")
args = parser.parse_args()
length_budget = args.length_capped_continuations
if length_budget is None:
length_budget = 1 if args.mode == "reproduce" else 3
report = run_live(
mode=args.mode,
timeout_seconds=args.timeout_seconds,
length_capped_continuations=max(1, int(length_budget)),
large_chars=max(1_000, int(args.large_chars)),
max_tokens=max(32, int(args.max_tokens)),
large_max_tokens=max(32, int(args.large_max_tokens)),
reasoning_model=str(args.reasoning_model),
)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(json.dumps(report, ensure_ascii=False))
return 0 if report.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())