欢迎光临
我们一直在努力

Python 标准库实现 API 网关验收脚本:成功率、延迟、Token 与小并发

接入 OpenAI 或 Anthropic 兼容网关时,curl 成功一次只能证明当时有一条链路返回了结果。要做一个范围受控且可复现的验收,还需要记录成功率、延迟分布、返回模型名、Token 用量、固定回答匹配率和每次请求的错误。

利益关系说明:作者参与一个 API 服务项目的运营。本文不推荐具体服务,也不把单次结果写成长期承诺;所有待测网关都应使用同一套方法自行验证。

本文实现一个只依赖 Python 标准库的小规模接口验收脚本。OpenAI / Anthropic“兼容”只描述请求和响应格式,不代表相关品牌授权、合作或背书。脚本最多发送 20 个请求、最高 8 并发,是功能检查器,不是压力测试器。

1. 先定义安全边界

脚本遵守以下约束:

  • API Key 只从环境变量读取,并要求为 8~4096 个可见 ASCII 字符,且不能是带可选正负号的纯数字;
  • Base URL 的原始值与规范化值都要检查;它必须是最终 HTTPS 端点,不允许 userinfo、查询串或 fragment;
  • 所有 HTTP 重定向都拒绝,避免认证头被带到另一个地址;
  • 固定提示词不包含个人信息、客户资料和业务代码;
  • 请求数和并发都有硬上限;
  • 单个响应体最多读取 256 KiB;
  • 响应必须是准确的 HTTP 200,并且只带一个受支持的 JSON Content-Type;随后严格检查 JSON、消息语义、文本、模型名和 Token schema;
  • 密钥若出现在输出参数中,脚本会在请求前拒绝;若出现在将被记录的响应内容、模型或 Token 字段中,该次响应会判为失败;两种情况都不回显原值;
  • 输出会记录端点、模型标识或不一致模型的短哈希、时间和错误,仍需限制访问权限与保留周期。
  • 固定提示词和上限为:

    FIXED_PROMPT = "Reply with exactly this text and nothing else: RELAY_AUDIT_OK"
    MAX_REQUESTS = 20
    MAX_CONCURRENCY = 8
    MAX_RESPONSE_BYTES = 256 * 1024

    固定回答匹配只能检查响应链路,不能证明模型身份。接口中的 model 字段同样只是“接口报告值”,不能独立作为底层模型证明。

    2. 用统一数据结构保存每次请求

    每条请求保存原始判断所需的最少字段:

    from dataclasses import dataclass

    @dataclass
    class Result:
    request_id: int
    ok: bool
    status: int | None
    latency_ms: int
    reported_model: str | None
    reported_model_matches_requested: bool | None
    reported_model_sha256_12: str | None
    input_tokens: int | None
    output_tokens: int | None
    exact_match: bool
    content_sha256_12: str | None
    error: str | None

    正文输出哈希而不是完整回答,既能比较固定回答是否变化,又减少把测试内容写进日志的风险。返回模型名只有与请求值完全一致时才明文保留;不一致时只记录短哈希并令总验收失败。哈希并不等于匿名化;真实业务日志仍应按数据分级和保留周期管理。

    3. HTTP 200 不等于验收通过

    OpenAI 兼容端点通常使用:

    POST {base_url}/chat/completions
    Authorization: Bearer <API_KEY>

    Anthropic 兼容端点通常使用:

    POST {base_url}/messages
    x-api-key: <API_KEY>
    anthropic-version: 2023-06-01

    两种格式的文本与 Token 字段位置不同,因此解析器应分开实现。响应不是准确的 HTTP 200、没有唯一且受支持的 application/json 或 application/*+json MIME、不是 JSON 对象、包含重复对象键或非有限数值、文本为空、模型名缺失、usage 缺失,或者 Token 超出非负 signed 64-bit 整数范围时,都应判为失败。若 Content-Type 声明 charset,只接受 UTF-8。OpenAI 响应还必须是 assistant 文本消息并以 finish_reason=stop 结束;Anthropic 响应必须是 assistant message、以 stop_reason=end_turn 结束,并拒绝混入非文本块。

    OpenAI 格式的严格解析核心如下:

    def _token_count(value, field):
    if (
    isinstance(value, bool)
    or not isinstance(value, int)
    or not 0 <= value <= (1 << 63) 1
    ):
    raise ValueError(f"{field} must be a non-negative signed 64-bit integer")
    return value

    def _extract_openai(data):
    choices = data.get("choices")
    if (
    not isinstance(choices, list)
    or len(choices) != 1
    or not isinstance(choices[0], dict)
    ):
    raise ValueError("OpenAI response must contain exactly one valid choice")
    message = choices[0].get("message")
    if not isinstance(message, dict) or not isinstance(message.get("content"), str):
    raise ValueError("OpenAI response message content is missing or invalid")
    if message.get("role") != "assistant":
    raise ValueError("OpenAI response message role must be assistant")
    if choices[0].get("finish_reason") != "stop":
    raise ValueError("OpenAI response finish_reason must be stop")
    usage = data.get("usage")
    if not isinstance(usage, dict):
    raise ValueError("OpenAI response usage is missing or invalid")
    model = data.get("model")
    if (
    not isinstance(model, str)
    or not model.isascii()
    or not 1 <= len(model) <= 200
    or any(not 0x21 <= ord(character) <= 0x7E for character in model)
    ):
    raise ValueError("OpenAI response model is missing or invalid")
    return (
    message["content"],
    model,
    _token_count(usage.get("prompt_tokens"), "prompt_tokens"),
    _token_count(usage.get("completion_tokens"), "completion_tokens"),
    )

    Anthropic 格式需要拼接 content 中类型为 text 的文本块,并严格读取 input_tokens、output_tokens。完整实现见文末,前面的短代码块只是讲解片段,不应彼此直接拼接执行。

    4. HTTPS 和拒绝重定向是密钥边界

    Python 默认的 URL opener 会处理常见重定向。对携带 Authorization 或 x-api-key 的 POST 请求,验收工具更稳妥的做法是要求调用者直接提供最终 HTTPS 端点,并拒绝所有重定向:

    class _RejectRedirects(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
    return None

    _NO_REDIRECT_OPENER = urllib.request.build_opener(_RejectRedirects())

    with _NO_REDIRECT_OPENER.open(request, timeout=timeout) as response:
    status = response.status
    raw = response.read(MAX_RESPONSE_BYTES + 1)
    if len(raw) > MAX_RESPONSE_BYTES:
    raise ValueError("response body exceeds the 256 KiB safety limit")

    Base URL 校验还应拒绝明文 HTTP、URL 中的用户名或密码、查询串、fragment、歧义端口、点路径、编码分隔符、旧式数字主机、伪括号主机、单标签主机、反斜杠和 RFC 3986 路径之外的字符,并输出规范化结果。不要把 API Key 放进 URL 路径;脚本会同时检查原始 URL、一次 URL 解码后的原始 URL、规范化 URL及其他待输出参数,防止规范化过程掩盖密钥。

    5. 正确认识延迟和 timeout

    使用单调时钟记录从发起请求到完整读取响应的耗时:

    started = time.perf_counter()
    with _NO_REDIRECT_OPENER.open(request, timeout=timeout) as response:
    status = response.status
    data = _safe_json_loads(_read_bounded(response))
    latency_ms = round((time.perf_counter() started) * 1000)

    这里记录的是完成请求的耗时,不是流式接口的首 Token 延迟。标准库 urlopen 的 timeout 是 socket 无数据活动超时,不是强制的总墙钟截止时间:持续缓慢返回数据的端点可能让总耗时超过该值。因此不要对不可信端点运行;若业务需要严格总截止时间,应在独立进程或外层任务系统中另设强制时限。

    6. 小并发要有硬限制

    脚本使用 ThreadPoolExecutor,但参数校验会拒绝超过上限的输入:

    if not 1 <= args.requests <= MAX_REQUESTS:
    parser.error(f"–requests must be between 1 and {MAX_REQUESTS}")

    if not 1 <= args.concurrency <= min(MAX_CONCURRENCY, args.requests):
    parser.error(
    f"–concurrency must be between 1 and min({MAX_CONCURRENCY}, requests)"
    )

    建议先单并发 3 次,再视服务条款、配额和测试窗口决定是否运行 2 并发 5 次。本文脚本不适合做生产压力测试。

    7. 汇总时保留时间与环境

    聚合结果至少包括:

    • UTC 起止时间和非敏感环境标签;
    • 成功数与成功率;
    • 固定回答逐字匹配率;
    • 返回模型与请求模型的匹配率,以及总体验收布尔值;
    • 成功请求的最小、中位、最大延迟;
    • 接口报告过的模型名集合;
    • 每条请求的状态、耗时、Token、内容哈希和错误类型。

    结构示例:

    {
    "run_started_at_utc": "2026-08-24T00:00:00Z",
    "run_completed_at_utc": "2026-08-24T00:00:04Z",
    "environment_label": "office-wifi",
    "target": {
    "base_url": "https://gateway.example/v1",
    "mode": "openai",
    "requested_model": "example-model"
    },
    "requests": 3,
    "successful": 3,
    "audit_passed": true,
    "success_rate": 1.0,
    "exact_match_rate": 1.0,
    "model_match_rate": 1.0,
    "latency_ms": {
    "min": 812,
    "median": 934,
    "max": 1280
    },
    "reported_models": ["example-model"]
    }

    .example 是保留示例域名;以上数字只展示输出结构,不是任何服务的实测结果。success_rate 只表示 HTTP 200 与 schema 通过率,最终应看同时包含固定回答和模型匹配条件的 audit_passed。完整输出还会包含逐条 results 和安全提示。环境标签只写诸如“office-wifi”或“cloud-test”,不要写用户名、IP、客户名或内部项目名。

    8. 运行方式:不要把真实密钥写进历史

    在正常交互式终端中,下面通过 Python 隐藏输入,并只为本次命令临时注入环境变量。密钥不会以明文出现在 shell 历史中,但在命令运行期间仍存在于进程环境;不要在不可信共享主机上执行。

    OpenAI 兼容格式:

    RELAY_AUDIT_API_KEY="$(
    python3 -c 'import getpass; print(getpass.getpass("Test API key: "))
    '
    )"
    python3 relay_audit.py \\
    –base-url 'https://gateway.example/v1' \\
    –mode openai \\
    –model '服务方文档中的准确模型名' \\
    –requests 3 \\
    –concurrency 1 \\
    –timeout 30 \\
    –environment-label 'office-wifi'

    Anthropic 格式只需将 –mode openai 改为 –mode anthropic,并填写该服务文档中的最终 HTTPS Base URL 和准确模型名。脚本不会跟随重定向。

    退出码含义:全部请求通过传输和 schema 检查、逐字返回 RELAY_AUDIT_OK,并且返回模型与请求模型一致时为 0;至少一条失败、回答不精确或模型不一致时为 1;参数或 API Key 绑定失败时为 2。

    运行结束后不要只看本地 JSON,还要到服务方控制台核对同一 UTC 时间段的模型、Token、请求状态和扣费。存在无法解释的差异时,先停止扩大请求量。结果文件使用受限目录保存,完成复盘后按既定周期删除。

    9. 完整脚本

    下面代码与离线测试使用的版本保持一致。请使用这一完整代码块,不要把前文讲解片段再次拼接进去。

    #!/usr/bin/env python3
    """Small, bounded audit client for OpenAI- or Anthropic-compatible relays.

    The tool intentionally caps request count and concurrency. It is a functional
    check, not a load-testing utility. API keys are read from an environment
    variable and are never printed or written to disk.
    """

    from __future__ import annotations

    import argparse
    import concurrent.futures
    import datetime
    import hashlib
    import ipaddress
    import json
    import math
    import os
    import re
    import statistics
    import sys
    import time
    import unicodedata
    import urllib.error
    import urllib.parse
    import urllib.request
    from collections.abc import Callable
    from dataclasses import asdict, dataclass
    from typing import Any, NoReturn, TypeVar

    FIXED_PROMPT = "Reply with exactly this text and nothing else: RELAY_AUDIT_OK"
    MAX_REQUESTS = 20
    MAX_CONCURRENCY = 8
    MAX_RESPONSE_BYTES = 256 * 1024
    MAX_TOKEN_COUNT = (1 << 63) 1
    T = TypeVar("T")
    _JSON_CONTENT_TYPE_RE = re.compile(
    r"^application/(?:json|[!#$%&'*+\\-.^_`|~0-9A-Za-z]+\\+json)"
    r'(?:\\s*;\\s*charset\\s*=\\s*(?:"(?:utf-8|utf8)"|(?:utf-8|utf8)))?$',
    re.IGNORECASE,
    )

    class _RejectRedirects(urllib.request.HTTPRedirectHandler):
    """Fail closed before credentials can be copied to a redirect target."""

    def redirect_request(
    self,
    req: urllib.request.Request,
    fp: Any,
    code: int,
    msg: str,
    headers: Any,
    newurl: str,
    ) > None:
    return None

    _NO_REDIRECT_OPENER = urllib.request.build_opener(_RejectRedirects())

    @dataclass
    class Result:
    request_id: int
    ok: bool
    status: int | None
    latency_ms: int
    reported_model: str | None
    reported_model_matches_requested: bool | None
    reported_model_sha256_12: str | None
    input_tokens: int | None
    output_tokens: int | None
    exact_match: bool
    content_sha256_12: str | None
    error: str | None

    @dataclass(frozen=True)
    class _ValidatedBaseURL:
    raw: str
    canonical: str

    def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) > dict[str, Any]:
    value: dict[str, Any] = {}
    for key, item in pairs:
    if key in value:
    raise ValueError("response JSON contains a duplicate object key")
    value[key] = item
    return value

    def _reject_nonfinite_constant(_value: str) > NoReturn:
    raise ValueError("response JSON contains a non-finite numeric constant")

    def _parse_finite_float(value: str) > float:
    parsed = float(value)
    if not math.isfinite(parsed):
    raise ValueError("response JSON contains an out-of-range floating-point value")
    return parsed

    def _validate_json_unicode(value: Any) > None:
    if isinstance(value, str):
    try:
    value.encode("utf-8")
    except UnicodeEncodeError as exc:
    raise ValueError("response JSON contains an invalid Unicode scalar") from exc
    elif isinstance(value, list):
    for item in value:
    _validate_json_unicode(item)
    elif isinstance(value, dict):
    for key, item in value.items():
    _validate_json_unicode(key)
    _validate_json_unicode(item)

    def _safe_json_loads(raw: bytes) > dict[str, Any]:
    value = json.loads(
    raw.decode("utf-8"),
    object_pairs_hook=_reject_duplicate_keys,
    parse_constant=_reject_nonfinite_constant,
    parse_float=_parse_finite_float,
    )
    if not isinstance(value, dict):
    raise ValueError("response JSON is not an object")
    _validate_json_unicode(value)
    return value

    def _validate_base_url(value: str) > str:
    """Accept only a final HTTPS endpoint without loggable URL secrets."""

    if (
    not value
    or len(value) > 2048
    or not value.isascii()
    or value != value.strip()
    or "\\\\" in value
    or any(not 0x21 <= ord(character) <= 0x7E for character in value)
    ):
    raise ValueError("base URL must be 1-2048 visible ASCII characters")
    try:
    parsed = urllib.parse.urlsplit(value)
    hostname = parsed.hostname
    port = parsed.port
    except ValueError as exc:
    raise ValueError("base URL is malformed") from exc
    if parsed.scheme != "https" or not hostname:
    raise ValueError("base URL must use HTTPS and include a hostname")
    if parsed.username is not None or parsed.password is not None:
    raise ValueError("base URL must not contain user information")
    if "?" in value or "#" in value:
    raise ValueError("base URL must not contain a query string or fragment")
    if parsed.netloc.endswith(":") or port is not None and not 1 <= port <= 65535:
    raise ValueError("base URL port is malformed or out of range")
    if hostname.endswith(".") or "%" in hostname:
    raise ValueError("base URL hostname is ambiguous")

    bracketed_host = "[" in parsed.netloc or "]" in parsed.netloc
    port_text: str | None = None
    if bracketed_host:
    closing_bracket = parsed.netloc.rfind("]")
    if closing_bracket >= 0 and parsed.netloc[closing_bracket + 1 :]:
    port_text = parsed.netloc[closing_bracket + 2 :]
    elif ":" in parsed.netloc:
    port_text = parsed.netloc.rsplit(":", 1)[1]
    if port_text is not None and (
    not port_text.isdecimal() or str(int(port_text)) != port_text
    ):
    raise ValueError("base URL port must use canonical decimal notation")
    try:
    address = ipaddress.ip_address(hostname)
    except ValueError:
    if bracketed_host:
    raise ValueError("bracketed base URL host must be a valid IPv6 address")
    labels = hostname.split(".")
    legacy_numeric_label = r"(?:0[xX][0-9A-Fa-f]+|0[0-7]+|[0-9]+)"
    if (
    len(hostname) > 253
    or len(labels) < 2
    or all(re.fullmatch(legacy_numeric_label, label) for label in labels)
    or any(
    not label
    or len(label) > 63
    or not re.fullmatch(r"[A-Za-z0-9-]+", label)
    or label.startswith("-")
    or label.endswith("-")
    for label in labels
    )
    ):
    raise ValueError("base URL hostname is malformed")
    canonical_host = hostname.lower()
    else:
    if bracketed_host and address.version != 6:
    raise ValueError("bracketed base URL host must be a valid IPv6 address")
    canonical_host = address.compressed
    if address.version == 6:
    canonical_host = f"[{canonical_host}]"

    path = parsed.path
    if re.search(r"%(?![0-9A-Fa-f]{2})", path):
    raise ValueError("base URL path contains malformed percent encoding")
    if not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+,;=:@/%-]*", path):
    raise ValueError("base URL path contains a character outside RFC 3986 pchar")
    try:
    decoded_path = urllib.parse.unquote_to_bytes(path).decode("utf-8")
    except UnicodeDecodeError as exc:
    raise ValueError("base URL path contains invalid UTF-8 encoding") from exc
    if (
    "%2f" in path.lower()
    or "%5c" in path.lower()
    or "%2e" in path.lower()
    or "%25" in path.lower()
    or "//" in path
    or "\\\\" in decoded_path
    or (decoded_path and not decoded_path.isprintable())
    or unicodedata.normalize("NFKC", decoded_path) != decoded_path
    or any(segment in (".", "..") for segment in decoded_path.split("/"))
    ):
    raise ValueError("base URL path contains ambiguous separators or dot segments")
    canonical_path = re.sub(
    r"%[0-9A-Fa-f]{2}", lambda match: match.group(0).upper(), path
    ).rstrip("/")
    canonical_netloc = canonical_host
    if port is not None and port != 443:
    canonical_netloc = f"{canonical_host}:{port}"
    return urllib.parse.urlunsplit(
    ("https", canonical_netloc, canonical_path, "", "")
    )

    def _base_url_argument(value: str) > _ValidatedBaseURL:
    return _ValidatedBaseURL(raw=value, canonical=_validate_base_url(value))

    def _validate_api_key(value: str) > str:
    if (
    not value
    or not 8 <= len(value) <= 4096
    or re.fullmatch(r"[+-]?[0-9]+", value)
    or value != value.strip()
    or any(not 0x21 <= ord(character) <= 0x7E for character in value)
    ):
    raise ValueError(
    "API key must be 8-4096 visible ASCII characters and not numeric-like"
    )
    return value

    def _read_bounded(response: Any) > bytes:
    raw = response.read(MAX_RESPONSE_BYTES + 1)
    if not isinstance(raw, bytes):
    raise ValueError("response body is not bytes")
    if len(raw) > MAX_RESPONSE_BYTES:
    raise ValueError("response body exceeds the 256 KiB safety limit")
    return raw

    def _token_count(value: Any, field: str) > int:
    if (
    isinstance(value, bool)
    or not isinstance(value, int)
    or not 0 <= value <= MAX_TOKEN_COUNT
    ):
    raise ValueError(f"{field} must be a non-negative signed 64-bit integer")
    return int(value)

    def _environment_label(value: str) > str:
    if (
    not value
    or len(value) > 64
    or value != value.strip()
    or not value.isprintable()
    or unicodedata.normalize("NFKC", value) != value
    ):
    raise ValueError("environment label must be 1-64 printable characters")
    return value

    def _model_name(value: str) > str:
    if (
    not value
    or len(value) > 200
    or not value.isascii()
    or value != value.strip()
    or any(not 0x21 <= ord(character) <= 0x7E for character in value)
    ):
    raise ValueError("model identifier must be 1-200 visible ASCII characters")
    return value

    def _api_key_env_name(value: str) > str:
    if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", value):
    raise ValueError("API key environment variable must be a valid shell name")
    return value

    def _mode_name(value: str) > str:
    if value not in ("openai", "anthropic"):
    raise ValueError("mode must be openai or anthropic")
    return value

    def _integer_argument(value: str) > int:
    try:
    return int(value)
    except ValueError as exc:
    raise ValueError("value must be an integer") from exc

    def _float_argument(value: str) > float:
    try:
    return float(value)
    except ValueError as exc:
    raise ValueError("value must be a number") from exc

    def _safe_argument_type(validator: Callable[[str], T]) > Callable[[str], T]:
    def parse(value: str) > T:
    try:
    return validator(value)
    except ValueError as exc:
    raise argparse.ArgumentTypeError(str(exc)) from None

    return parse

    class _SafeArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) > NoReturn:
    if "ignored explicit argument" in message:
    message = "help option does not accept a value"
    elif message.startswith(("unrecognized arguments:", "ambiguous option:")):
    message = "unrecognized command-line arguments (values redacted)"
    super().error(message)

    def _utc_text() > str:
    return (
    datetime.datetime.now(datetime.timezone.utc)
    .isoformat()
    .replace("+00:00", "Z")
    )

    def _redact_secret_values(value: Any, secret: str) > Any:
    if isinstance(value, str):
    return value.replace(secret, "[REDACTED]")
    if isinstance(value, list):
    return [_redact_secret_values(item, secret) for item in value]
    if isinstance(value, dict):
    return {
    key: _redact_secret_values(item, secret) for key, item in value.items()
    }
    if isinstance(value, (int, float)) and not isinstance(value, bool):
    return "[REDACTED]" if secret in str(value) else value
    return value

    def _extract_openai(data: dict[str, Any]) > tuple[str, str, int, int]:
    choices = data.get("choices")
    if (
    not isinstance(choices, list)
    or len(choices) != 1
    or not isinstance(choices[0], dict)
    ):
    raise ValueError("OpenAI response must contain exactly one valid choice")
    message = choices[0].get("message")
    if not isinstance(message, dict) or not isinstance(message.get("content"), str):
    raise ValueError("OpenAI response message content is missing or invalid")
    if message.get("role") != "assistant":
    raise ValueError("OpenAI response message role must be assistant")
    allowed_empty_payloads = {
    "tool_calls": (None, []),
    "function_call": (None, {}),
    "refusal": (None, ""),
    "audio": (None, {}),
    }
    for field, allowed in allowed_empty_payloads.items():
    if field in message and message[field] not in allowed:
    raise ValueError("OpenAI response contains a non-text message payload")
    if choices[0].get("finish_reason") != "stop":
    raise ValueError("OpenAI response finish_reason must be stop")
    usage = data.get("usage")
    if not isinstance(usage, dict):
    raise ValueError("OpenAI response usage is missing or invalid")
    model = data.get("model")
    if not isinstance(model, str):
    raise ValueError("OpenAI response model is missing or invalid")
    return (
    message["content"],
    _model_name(model),
    _token_count(usage.get("prompt_tokens"), "prompt_tokens"),
    _token_count(usage.get("completion_tokens"), "completion_tokens"),
    )

    def _extract_anthropic(data: dict[str, Any]) > tuple[str, str, int, int]:
    if data.get("type") != "message" or data.get("role") != "assistant":
    raise ValueError("Anthropic response must be an assistant message")
    if data.get("stop_reason") != "end_turn":
    raise ValueError("Anthropic response stop_reason must be end_turn")
    blocks = data.get("content")
    if not isinstance(blocks, list) or not blocks:
    raise ValueError("Anthropic response content is missing or invalid")
    texts = []
    for block in blocks:
    if (
    not isinstance(block, dict)
    or block.get("type") != "text"
    or not isinstance(block.get("text"), str)
    ):
    raise ValueError("Anthropic response contains an invalid or non-text block")
    texts.append(block["text"])
    usage = data.get("usage")
    if not isinstance(usage, dict):
    raise ValueError("Anthropic response usage is missing or invalid")
    model = data.get("model")
    if not isinstance(model, str):
    raise ValueError("Anthropic response model is missing or invalid")
    return (
    "".join(texts),
    _model_name(model),
    _token_count(usage.get("input_tokens"), "input_tokens"),
    _token_count(usage.get("output_tokens"), "output_tokens"),
    )

    def _request_once(
    request_id: int,
    *,
    base_url: str,
    api_key: str,
    model: str,
    mode: str,
    timeout: float,
    ) > Result:
    started = time.perf_counter()
    status: int | None = None

    try:
    raw_base_url = base_url
    base_url = _validate_base_url(base_url)
    api_key = _validate_api_key(api_key)
    model = _model_name(model)
    if (
    api_key in raw_base_url
    or api_key in urllib.parse.unquote(raw_base_url)
    or api_key in base_url
    or api_key in urllib.parse.unquote(base_url)
    or api_key in model
    ):
    raise ValueError("API key appears in an output-bound argument")
    if mode == "openai":
    url = f"{base_url}/chat/completions"
    headers = {"Authorization": f"Bearer {api_key}"}
    payload = {
    "model": model,
    "messages": [{"role": "user", "content": FIXED_PROMPT}],
    "temperature": 0,
    "max_tokens": 32,
    "stream": False,
    }
    extractor = _extract_openai
    elif mode == "anthropic":
    url = f"{base_url}/messages"
    headers = {
    "x-api-key": api_key,
    "anthropic-version": "2023-06-01",
    }
    payload = {
    "model": model,
    "messages": [{"role": "user", "content": FIXED_PROMPT}],
    "temperature": 0,
    "max_tokens": 32,
    "stream": False,
    }
    extractor = _extract_anthropic
    else:
    raise ValueError("mode must be openai or anthropic")

    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    headers.update(
    {
    "Accept": "application/json",
    "Content-Type": "application/json",
    "User-Agent": "relay-audit/0.1.0-local",
    }
    )
    request = urllib.request.Request(url, data=body, headers=headers, method="POST")
    with _NO_REDIRECT_OPENER.open(request, timeout=timeout) as response:
    status = response.status
    if isinstance(status, bool) or not isinstance(status, int):
    raise ValueError("response status is not an integer")
    if status != 200:
    raise ValueError("synchronous completion response must use HTTP 200")
    response_headers = getattr(response, "headers", None)
    get_all = getattr(response_headers, "get_all", None)
    if callable(get_all):
    content_type_values = get_all("Content-Type")
    content_type = (
    content_type_values[0]
    if isinstance(content_type_values, list)
    and len(content_type_values) == 1
    else None
    )
    else:
    content_type = (
    response_headers.get("Content-Type")
    if response_headers is not None
    else None
    )
    if (
    not isinstance(content_type, str)
    or not content_type.isascii()
    or any(not 0x20 <= ord(character) <= 0x7E for character in content_type)
    ):
    raise ValueError("response Content-Type is missing or invalid")
    if _JSON_CONTENT_TYPE_RE.fullmatch(content_type.strip()) is None:
    raise ValueError("response Content-Type must be application JSON")
    data = _safe_json_loads(_read_bounded(response))
    content, reported_model, input_tokens, output_tokens = extractor(data)
    if (
    api_key in content
    or api_key in reported_model
    or api_key in str(input_tokens)
    or api_key in str(output_tokens)
    ):
    raise ValueError("response contained protected credential data and was redacted")
    if not content.strip():
    raise ValueError("response content is empty")
    reported_model_matches_requested = reported_model == model
    return Result(
    request_id=request_id,
    ok=True,
    status=status,
    latency_ms=round((time.perf_counter() started) * 1000),
    reported_model=(
    reported_model if reported_model_matches_requested else None
    ),
    reported_model_matches_requested=reported_model_matches_requested,
    reported_model_sha256_12=hashlib.sha256(
    reported_model.encode("utf-8")
    ).hexdigest()[:12],
    input_tokens=input_tokens,
    output_tokens=output_tokens,
    exact_match=content == "RELAY_AUDIT_OK",
    content_sha256_12=hashlib.sha256(content.encode("utf-8")).hexdigest()[:12],
    error=None,
    )
    except urllib.error.HTTPError as exc:
    try:
    status = exc.code
    suffix = " (redirects are refused)" if 300 <= exc.code < 400 else ""
    error = f"HTTP {exc.code}{suffix}"
    finally:
    try:
    exc.close()
    except Exception:
    pass
    except urllib.error.URLError as exc:
    error = f"network error: {type(exc.reason).__name__}"
    except (TimeoutError, UnicodeDecodeError, ValueError) as exc:
    safe_message = str(exc).replace(api_key, "[REDACTED]") if api_key else str(exc)
    error = f"response error: {safe_message}"
    except Exception as exc: # Keep the audit summary useful without leaking secrets.
    error = f"unexpected error: {type(exc).__name__}"

    return Result(
    request_id=request_id,
    ok=False,
    status=status,
    latency_ms=round((time.perf_counter() started) * 1000),
    reported_model=None,
    reported_model_matches_requested=None,
    reported_model_sha256_12=None,
    input_tokens=None,
    output_tokens=None,
    exact_match=False,
    content_sha256_12=None,
    error=error,
    )

    def _is_hash12(value: Any) > bool:
    return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{12}", value) is not None

    def _result_has_valid_success_schema(result: Result, requested_model: str) > bool:
    if (
    result.ok is not True
    or isinstance(result.status, bool)
    or not isinstance(result.status, int)
    or result.status != 200
    or isinstance(result.request_id, bool)
    or not isinstance(result.request_id, int)
    or result.request_id < 1
    or isinstance(result.latency_ms, bool)
    or not isinstance(result.latency_ms, int)
    or result.latency_ms < 0
    or result.error is not None
    or not isinstance(result.exact_match, bool)
    or not _is_hash12(result.content_sha256_12)
    or not isinstance(result.reported_model_matches_requested, bool)
    or not _is_hash12(result.reported_model_sha256_12)
    ):
    return False
    try:
    _token_count(result.input_tokens, "input_tokens")
    _token_count(result.output_tokens, "output_tokens")
    except ValueError:
    return False
    if result.exact_match and result.content_sha256_12 != hashlib.sha256(
    b"RELAY_AUDIT_OK"
    ).hexdigest()[:12]:
    return False
    if result.reported_model_matches_requested:
    return (
    result.reported_model == requested_model
    and result.reported_model_sha256_12
    == hashlib.sha256(requested_model.encode("utf-8")).hexdigest()[:12]
    )
    return result.reported_model is None

    def _build_summary(
    results: list[Result],
    *,
    base_url: str,
    model: str,
    mode: str,
    environment_label: str | None = None,
    run_started_at_utc: str | None = None,
    run_completed_at_utc: str | None = None,
    ) > dict[str, Any]:
    successful = [
    result
    for result in results
    if _result_has_valid_success_schema(result, model)
    ]
    latencies = [result.latency_ms for result in successful]
    models = sorted({result.reported_model for result in successful if result.reported_model})
    request_ids_are_complete = [result.request_id for result in results] == list(
    range(1, len(results) + 1)
    )
    denominator = len(results) or 1
    return {
    "run_started_at_utc": run_started_at_utc or _utc_text(),
    "run_completed_at_utc": run_completed_at_utc or _utc_text(),
    "environment_label": environment_label,
    "target": {"base_url": base_url, "mode": mode, "requested_model": model},
    "requests": len(results),
    "successful": len(successful),
    "audit_passed": all(
    _result_has_valid_success_schema(result, model)
    and result.exact_match
    and result.reported_model_matches_requested is True
    for result in results
    )
    and bool(results)
    and request_ids_are_complete,
    "success_rate": round(len(successful) / denominator, 4),
    "exact_match_rate": round(
    sum(result.exact_match for result in successful) / denominator, 4
    ),
    "model_match_rate": round(
    sum(
    result.reported_model_matches_requested is True
    for result in successful
    )
    / denominator,
    4,
    ),
    "latency_ms": {
    "min": min(latencies) if latencies else None,
    "median": round(statistics.median(latencies)) if latencies else None,
    "max": max(latencies) if latencies else None,
    },
    "reported_models": models,
    "results": [asdict(result) for result in results],
    "notes": [
    "This is a small functional check, not a model-identity proof.",
    "Compare the provider dashboard's usage and billing records separately.",
    "Do not test with secrets, personal data, or production workloads.",
    "The timeout is a socket inactivity limit, not a hard wall-clock deadline.",
    "The result contains endpoint and model identifiers; restrict access and retention.",
    "Mismatched models are stored only as short hashes; credential echoes are redacted.",
    ],
    }

    def parse_args(argv: list[str] | None = None) > argparse.Namespace:
    parser = _SafeArgumentParser(
    prog="relay_audit.py",
    description="Run a small, reproducible API relay check.",
    allow_abbrev=False,
    )
    parser.add_argument(
    "–base-url",
    required=True,
    type=_safe_argument_type(_base_url_argument),
    help="Final HTTPS API base URL, usually ending in /v1; redirects are refused.",
    )
    parser.add_argument(
    "–model",
    required=True,
    type=_safe_argument_type(_model_name),
    help="Exact model identifier shown by the provider (max 200 chars).",
    )
    parser.add_argument(
    "–mode", type=_safe_argument_type(_mode_name), default="openai"
    )
    parser.add_argument(
    "–requests", type=_safe_argument_type(_integer_argument), default=3
    )
    parser.add_argument(
    "–concurrency", type=_safe_argument_type(_integer_argument), default=1
    )
    parser.add_argument(
    "–timeout",
    type=_safe_argument_type(_float_argument),
    default=30.0,
    help="Socket inactivity timeout in seconds; not a hard wall-clock deadline.",
    )
    parser.add_argument(
    "–api-key-env",
    type=_safe_argument_type(_api_key_env_name),
    default="RELAY_AUDIT_API_KEY",
    help="Environment variable containing the test key.",
    )
    parser.add_argument(
    "–environment-label",
    type=_safe_argument_type(_environment_label),
    help="Optional non-sensitive network/test environment label (max 64 chars).",
    )
    args = parser.parse_args(argv)
    validated_base_url = args.base_url
    args.base_url_raw = validated_base_url.raw
    args.base_url = validated_base_url.canonical
    if not 1 <= args.requests <= MAX_REQUESTS:
    parser.error(f"–requests must be between 1 and {MAX_REQUESTS}")
    if not 1 <= args.concurrency <= min(MAX_CONCURRENCY, args.requests):
    parser.error(f"–concurrency must be between 1 and min({MAX_CONCURRENCY}, requests)")
    if not 1 <= args.timeout <= 120:
    parser.error("–timeout must be between 1 and 120 seconds")
    return args

    def main(argv: list[str] | None = None) > int:
    args = parse_args(argv)
    api_key = os.environ.get(args.api_key_env)
    if not api_key:
    print("Missing API key environment variable.", file=sys.stderr)
    return 2
    try:
    api_key = _validate_api_key(api_key)
    except ValueError as exc:
    print(f"Invalid API key: {exc}", file=sys.stderr)
    return 2
    output_bound_values = (
    args.base_url_raw,
    urllib.parse.unquote(args.base_url_raw),
    args.base_url,
    urllib.parse.unquote(args.base_url),
    args.model,
    args.environment_label or "",
    )
    if any(api_key in value for value in output_bound_values):
    print(
    "Refusing to run: the API key also appears in an output-bound argument.",
    file=sys.stderr,
    )
    return 2

    run_started_at_utc = _utc_text()
    with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as executor:
    futures = [
    executor.submit(
    _request_once,
    request_id,
    base_url=args.base_url,
    api_key=api_key,
    model=args.model,
    mode=args.mode,
    timeout=args.timeout,
    )
    for request_id in range(1, args.requests + 1)
    ]
    results = [future.result() for future in futures]

    results.sort(key=lambda result: result.request_id)
    summary = _build_summary(
    results,
    base_url=args.base_url,
    model=args.model,
    mode=args.mode,
    environment_label=args.environment_label,
    run_started_at_utc=run_started_at_utc,
    run_completed_at_utc=_utc_text(),
    )
    exit_code = 0 if summary["audit_passed"] else 1
    summary = _redact_secret_values(summary, api_key)
    serialized = json.dumps(summary, ensure_ascii=False, indent=2)
    escaped_api_key = json.dumps(api_key, ensure_ascii=False)[1:1]
    if api_key in serialized or escaped_api_key in serialized:
    print("null")
    return 1
    print(serialized)
    return exit_code

    if __name__ == "__main__":
    raise SystemExit(main())

    把脚本放进自己的 tools/ 或验收目录,先运行离线单元测试,再使用非敏感测试账户做小规模检查。是否接入应以自己的验收记录为准。

    赞(0)
    未经允许不得转载:171主机测评 » Python 标准库实现 API 网关验收脚本:成功率、延迟、Token 与小并发
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址