252 lines
9.5 KiB
Python
252 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_BASE_URL = "https://api.openai.com/v1/responses"
|
|
DEFAULT_HOST = urlparse(DEFAULT_BASE_URL).hostname or "api.openai.com"
|
|
ALLOWED_PATH_PREFIX = "/v1/responses"
|
|
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
print(message, file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
|
|
def validate_base_url(base_url: str, allow_insecure_localhost: bool, allow_custom_base_url: bool) -> None:
|
|
parsed = urlparse(base_url)
|
|
host = parsed.hostname or ""
|
|
if not parsed.path.startswith(ALLOWED_PATH_PREFIX):
|
|
fail(f"provider endpoint path must start with {ALLOWED_PATH_PREFIX}")
|
|
if parsed.scheme == "https" and (host == DEFAULT_HOST or allow_custom_base_url):
|
|
return
|
|
if parsed.scheme == "https":
|
|
fail("custom provider host requires --allow-custom-base-url")
|
|
if parsed.scheme == "http" and allow_insecure_localhost and (parsed.hostname or "") in LOCAL_HOSTS:
|
|
return
|
|
fail("provider runner requires HTTPS; use --allow-insecure-localhost only for local test servers")
|
|
|
|
|
|
def load_request() -> dict[str, Any]:
|
|
raw = sys.stdin.read()
|
|
if not raw.strip():
|
|
fail("provider runner requires a JSON request on stdin")
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
fail(f"invalid JSON request: {exc}")
|
|
if not isinstance(payload, dict):
|
|
fail("runner request must be a JSON object")
|
|
return payload
|
|
|
|
|
|
def safe_relative(path_value: str) -> Path | None:
|
|
path = Path(path_value)
|
|
if path.is_absolute() or ".." in path.parts:
|
|
return None
|
|
return path
|
|
|
|
|
|
def read_input_files(paths: Any, input_root: Path, max_chars: int) -> list[dict[str, str]]:
|
|
if not isinstance(paths, list):
|
|
return []
|
|
files: list[dict[str, str]] = []
|
|
for item in paths:
|
|
rel = safe_relative(str(item))
|
|
if rel is None:
|
|
files.append({"path": str(item), "status": "skipped-unsafe-path", "content": ""})
|
|
continue
|
|
path = input_root / rel
|
|
if not path.exists() or not path.is_file():
|
|
files.append({"path": str(item), "status": "missing", "content": ""})
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
files.append({"path": str(item), "status": "loaded", "content": text[:max_chars]})
|
|
return files
|
|
|
|
|
|
def read_skill_instructions(path: Path, max_chars: int) -> str:
|
|
if not path.exists() or not path.is_file():
|
|
return ""
|
|
return path.read_text(encoding="utf-8", errors="replace")[:max_chars]
|
|
|
|
|
|
def build_provider_input(request: dict[str, Any], skill_text: str, input_files: list[dict[str, str]]) -> str:
|
|
variant = str(request.get("variant", ""))
|
|
lines = [
|
|
"You are producing one output for a Yao Meta Skill output-eval case.",
|
|
f"Case id: {request.get('case_id', '')}",
|
|
f"Variant: {variant}",
|
|
"",
|
|
"User task:",
|
|
str(request.get("prompt", "")),
|
|
"",
|
|
]
|
|
if variant == "with_skill":
|
|
lines.extend(
|
|
[
|
|
"Use the skill instructions below as the operating guidance. Preserve concrete evidence paths and boundaries.",
|
|
"",
|
|
"Skill instructions:",
|
|
skill_text or "(Skill instructions were not available.)",
|
|
"",
|
|
]
|
|
)
|
|
else:
|
|
lines.extend(
|
|
[
|
|
"Produce a direct baseline answer without using the Yao Meta Skill guidance.",
|
|
"Do not invent files, reports, governance evidence, or hidden review artifacts.",
|
|
"",
|
|
]
|
|
)
|
|
if input_files:
|
|
lines.append("Input files:")
|
|
for item in input_files:
|
|
lines.append(f"--- {item['path']} ({item['status']}) ---")
|
|
if item["content"]:
|
|
lines.append(item["content"])
|
|
lines.append("")
|
|
lines.extend(
|
|
[
|
|
"Return only the final user-facing answer for this variant.",
|
|
"Do not mention or copy any fixture output from the eval case.",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def response_text(payload: dict[str, Any]) -> str:
|
|
if isinstance(payload.get("output_text"), str):
|
|
return str(payload["output_text"])
|
|
parts: list[str] = []
|
|
output = payload.get("output")
|
|
if isinstance(output, list):
|
|
for item in output:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
content = item.get("content")
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if isinstance(block.get("text"), str):
|
|
parts.append(str(block["text"]))
|
|
if parts:
|
|
return "\n".join(part for part in parts if part).strip()
|
|
choices = payload.get("choices")
|
|
if isinstance(choices, list) and choices:
|
|
first = choices[0]
|
|
if isinstance(first, dict):
|
|
message = first.get("message", {})
|
|
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
return str(message["content"])
|
|
return ""
|
|
|
|
|
|
def observed_usage(payload: dict[str, Any]) -> dict[str, Any]:
|
|
usage = payload.get("usage", {})
|
|
if not isinstance(usage, dict):
|
|
return {}
|
|
input_tokens = usage.get("input_tokens", usage.get("prompt_tokens"))
|
|
output_tokens = usage.get("output_tokens", usage.get("completion_tokens"))
|
|
total_tokens = usage.get("total_tokens")
|
|
result: dict[str, Any] = {}
|
|
if input_tokens is not None:
|
|
result["input_tokens"] = int(input_tokens)
|
|
if output_tokens is not None:
|
|
result["output_tokens"] = int(output_tokens)
|
|
if total_tokens is not None:
|
|
result["total_tokens"] = int(total_tokens)
|
|
if result:
|
|
result["estimated"] = False
|
|
return result
|
|
|
|
|
|
def call_provider(base_url: str, api_key: str, model: str, provider_input: str, timeout_seconds: float) -> dict[str, Any]:
|
|
body = json.dumps({"model": model, "input": provider_input}, ensure_ascii=False).encode("utf-8")
|
|
request = Request(
|
|
base_url,
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=timeout_seconds) as response:
|
|
response_body = response.read().decode("utf-8", errors="replace")
|
|
except HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
|
fail(f"provider request failed with HTTP {exc.code}: {detail}")
|
|
except URLError as exc:
|
|
fail(f"provider request failed: {exc.reason}")
|
|
try:
|
|
payload = json.loads(response_body)
|
|
except json.JSONDecodeError as exc:
|
|
fail(f"provider returned invalid JSON: {exc}")
|
|
if not isinstance(payload, dict):
|
|
fail("provider response must be a JSON object")
|
|
return payload
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Provider-backed output-eval runner for run_output_execution.py. "
|
|
"Requires real model credentials and reports execution_kind=model only after an HTTP provider call."
|
|
)
|
|
)
|
|
parser.add_argument("--provider", default="openai", help="Provider label to write into execution evidence.")
|
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="OpenAI Responses API compatible endpoint.")
|
|
parser.add_argument("--model", default=os.environ.get("YAO_OUTPUT_EVAL_MODEL", ""))
|
|
parser.add_argument("--api-key-env", default="OPENAI_API_KEY")
|
|
parser.add_argument("--input-root", default=str(ROOT / "evals" / "output"))
|
|
parser.add_argument("--skill-file", default=str(ROOT / "SKILL.md"))
|
|
parser.add_argument("--timeout-seconds", type=float, default=60.0)
|
|
parser.add_argument("--max-input-file-chars", type=int, default=6000)
|
|
parser.add_argument("--max-skill-chars", type=int, default=8000)
|
|
parser.add_argument("--allow-insecure-localhost", action="store_true")
|
|
parser.add_argument("--allow-custom-base-url", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
validate_base_url(args.base_url, args.allow_insecure_localhost, args.allow_custom_base_url)
|
|
if not args.model:
|
|
fail("missing model; pass --model or set YAO_OUTPUT_EVAL_MODEL")
|
|
api_key = os.environ.get(args.api_key_env, "")
|
|
if not api_key:
|
|
fail(f"missing API key env: {args.api_key_env}")
|
|
|
|
request = load_request()
|
|
input_files = read_input_files(request.get("input_files", []), Path(args.input_root).resolve(), args.max_input_file_chars)
|
|
skill_text = read_skill_instructions(Path(args.skill_file).resolve(), args.max_skill_chars)
|
|
provider_input = build_provider_input(request, skill_text, input_files)
|
|
response = call_provider(args.base_url, api_key, args.model, provider_input, args.timeout_seconds)
|
|
output = response_text(response)
|
|
if not output:
|
|
fail("provider response did not contain output text")
|
|
result = {
|
|
"output": output,
|
|
"execution_kind": "model",
|
|
"provider": args.provider,
|
|
"model": args.model,
|
|
"usage": observed_usage(response),
|
|
"response_id": str(response.get("id", "")),
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|