SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
10.4 KB · 273 lines python
Raw Blame History
1#!/usr/bin/env python32#3#  verify.py4#  Zyquo Router5#6#  Author: Simon-Pierre Boucher7#  Mail: contact@spboucher.ai8#9#  Phase 7 verification harness. Drives EVERY chat model in the catalog10#  through the router's local endpoint using the official OpenAI Python SDK11#  (never the upstreams directly): non-streaming, streaming (SDK-parsed12#  chunk discipline), tools / vision / reasoning where the catalog says the13#  model supports them. Emits the compatibility matrix as Markdown.14#15#  Usage:  verify.py [--base http://127.0.0.1:8787/v1] [--providers xai,mistral]16#          [--models id1,id2] [--workers 6] [--out docs/VERIFICATION.md]17#18import argparse19import base6420import concurrent.futures21import json22import sys23import threading24import time25import urllib.request2627from openai import OpenAI, APIError, APIStatusError2829TOOLS = [{30    "type": "function",31    "function": {32        "name": "get_weather",33        "description": "Get current weather for a location",34        "parameters": {35            "type": "object",36            "properties": {"location": {"type": "string"}},37            "required": ["location"],38        },39    },40}]4142# 64×64 solid red PNG (1×1 images are rejected by some providers).43TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAb0lEQVR4nO3PAQkAAAyEwO9feoshgnABdLep8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3IPanc8OLDQitxAAAAAElFTkSuQmCC"4445print_lock = threading.Lock()464748def log(msg):49    with print_lock:50        print(msg, flush=True)515253class ModelResult:54    def __init__(self, model_id, meta):55        self.id = model_id56        self.meta = meta or {}57        self.non_stream = None   # "PASS" / "FAIL: …" / "RATE-LIMITED"58        self.stream = None59        self.tools = "n/a"60        self.vision = "n/a"61        self.reasoning = "n/a"6263    @property64    def ok(self):65        checks = [self.non_stream, self.stream, self.tools, self.vision, self.reasoning]66        return all(c in (None, "n/a") or str(c).startswith(("PASS", "RATE-LIMITED", "SKIP")) for c in checks)676869def classify(err):70    if isinstance(err, APIStatusError) and err.status_code == 429:71        return "RATE-LIMITED"72    text = str(err)73    return "FAIL: " + text[:160].replace("\n", " ").replace("|", "/")747576def check_non_stream(client, result):77    r = client.chat.completions.create(78        model=result.id, max_tokens=32,79        messages=[{"role": "user", "content": "Reply with exactly: OK"}],80    )81    assert r.object == "chat.completion", f"object={r.object}"82    assert r.model == result.id, f"model echo {r.model}"83    assert r.choices[0].message.role == "assistant"84    assert r.choices[0].finish_reason in ("stop", "length"), f"finish={r.choices[0].finish_reason}"85    assert r.usage and r.usage.total_tokens > 0, "usage missing"86    return "PASS"878889def check_stream(client, result):90    text, finish, usage, got_role = "", None, None, False91    stream = client.chat.completions.create(92        model=result.id, max_tokens=64, stream=True,93        stream_options={"include_usage": True},94        messages=[{"role": "user", "content": "Count from 1 to 3, digits only."}],95    )96    first = True97    for chunk in stream:98        assert chunk.object == "chat.completion.chunk", f"chunk object={chunk.object}"99        if chunk.usage:100            usage = chunk.usage101        if not chunk.choices:102            continue103        delta = chunk.choices[0].delta104        if first and delta.role == "assistant":105            got_role = True106        first = False107        if chunk.choices[0].finish_reason:108            finish = chunk.choices[0].finish_reason109        text += delta.content or ""110    assert got_role, "no role delta on first chunk"111    assert finish in ("stop", "length"), f"finish={finish}"112    assert usage is not None and usage.total_tokens > 0, "usage chunk missing"113    return "PASS"114115116def check_tools(client, result, tool_choice="auto"):117    calls = {}118    stream = client.chat.completions.create(119        model=result.id, max_tokens=300, stream=True,120        tools=TOOLS, tool_choice=tool_choice,121        messages=[{"role": "user", "content": "What's the weather in Paris? Use the get_weather tool."}],122    )123    for chunk in stream:124        if not chunk.choices:125            continue126        for tc in chunk.choices[0].delta.tool_calls or []:127            entry = calls.setdefault(tc.index, {"id": None, "name": "", "args": ""})128            if tc.id:129                entry["id"] = tc.id130            if tc.function and tc.function.name:131                entry["name"] = tc.function.name132            if tc.function and tc.function.arguments:133                entry["args"] += tc.function.arguments134    assert calls, "no tool call streamed"135    call = calls[min(calls)]136    assert call["id"], "tool call id missing"137    assert call["name"] == "get_weather", f"name={call['name']}"138    try:139        args = json.loads(call["args"])140        note = ""141    except json.JSONDecodeError:142        # DashScope qwq repeats the complete arguments object per delta —143        # accept the first object but flag the upstream quirk.144        args, _ = json.JSONDecoder().raw_decode(call["args"])145        note = " (upstream repeats args)"146    assert "location" in args, f"args={args}"147    return "PASS" + note148149150def check_vision(client, result):151    r = client.chat.completions.create(152        model=result.id, max_tokens=1024,153        messages=[{"role": "user", "content": [154            {"type": "text", "text": "One word: what color is this image?"},155            {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{TINY_PNG}"}},156        ]}],157    )158    content = r.choices[0].message.content or ""159    assert content.strip(), "empty vision answer"160    return "PASS"161162163def check_reasoning(client, result):164    r = client.chat.completions.create(165        model=result.id, max_tokens=2048,166        extra_body={"reasoning_effort": "low"},167        messages=[{"role": "user", "content": "What is 17*23? Reply with the number only."}],168    )169    message = r.choices[0].message170    reasoning = getattr(message, "reasoning_content", None)171    if reasoning is None and message.model_extra:172        reasoning = message.model_extra.get("reasoning_content")173    details = (r.usage.completion_tokens_details if r.usage else None)174    reasoning_tokens = getattr(details, "reasoning_tokens", None) if details else None175    if (reasoning and reasoning.strip()) or (reasoning_tokens or 0) > 0:176        return "PASS"177    # The call accepted reasoning_effort and answered, but the provider keeps178    # reasoning server-side (OpenAI o-series/gpt-5*, some hosted models).179    assert (r.choices[0].message.content or "").strip(), "empty reasoning answer"180    return "PASS (hidden)"181182183def verify_model(base, entry):184    client = OpenAI(base_url=base, api_key="zyquo-verify", timeout=180, max_retries=0)185    meta = entry.get("x_zyquo") or {}186    result = ModelResult(entry["id"], meta)187188    # Deep-research models run for minutes — beyond any sane harness timeout.189    long_running = "deep-research" in result.id190191    for name, fn, gated in [192        ("non_stream", check_non_stream, True),193        ("stream", check_stream, not long_running),194        ("tools", check_tools, meta.get("tools")),195        ("vision", check_vision, meta.get("vision")),196        ("reasoning", check_reasoning, meta.get("reasoning") and not long_running),197    ]:198        if not gated:199            if long_running and name in ("stream", "reasoning"):200                setattr(result, name, "SKIP (long-running)")201            continue202        try:203            setattr(result, name, fn(client, result))204        except AssertionError as err:205            outcome = f"FAIL: {err}"206            # Weak tool-callers may ignore "auto" — one retry forcing the call.207            if name == "tools" and "no tool call" in str(err):208                try:209                    outcome = check_tools(client, result, tool_choice="required") + " (required)"210                except Exception as retry_err:  # noqa: BLE001211                    outcome = f"FAIL: no tool call with auto; required → {classify(retry_err)[:80]}"212            setattr(result, name, outcome)213        except (APIError, Exception) as err:  # noqa: BLE001 — harness must not die214            setattr(result, name, classify(err))215        time.sleep(0.3)216217    status = "OK " if result.ok else "!! "218    log(f"{status}{result.id}: ns={result.non_stream} st={result.stream} "219        f"tools={result.tools} vision={result.vision} reasoning={result.reasoning}")220    return result221222223def main():224    parser = argparse.ArgumentParser()225    parser.add_argument("--base", default="http://127.0.0.1:8787/v1")226    parser.add_argument("--providers", default="")227    parser.add_argument("--models", default="")228    parser.add_argument("--workers", type=int, default=6)229    parser.add_argument("--out", default="docs/VERIFICATION.md")230    args = parser.parse_args()231232    with urllib.request.urlopen(f"{args.base}/models") as response:233        catalog = json.load(response)["data"]234235    if args.providers:236        wanted = set(args.providers.split(","))237        catalog = [m for m in catalog if m["owned_by"] in wanted]238    if args.models:239        wanted = set(args.models.split(","))240        catalog = [m for m in catalog if m["id"] in wanted]241242    log(f"Verifying {len(catalog)} models via {args.base}")243    results = []244    with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:245        futures = [pool.submit(verify_model, args.base, entry) for entry in catalog]246        for future in concurrent.futures.as_completed(futures):247            results.append(future.result())248249    results.sort(key=lambda r: r.id)250    failed = [r for r in results if not r.ok]251252    lines = [253        "# Zyquo Router — Phase 7 Compatibility Matrix",254        "",255        f"Generated {time.strftime('%Y-%m-%d %H:%M')} by `scripts/verify.py` — every request",256        "went through the router's local endpoint using the official OpenAI Python SDK.",257        "",258        f"**{len(results)} models · {len(results) - len(failed)} green · {len(failed)} failing**",259        "",260        "| Model | Non-stream | Stream | Tools | Vision | Reasoning |",261        "|---|---|---|---|---|---|",262    ]263    for r in results:264        lines.append(f"| `{r.id}` | {r.non_stream} | {r.stream} | {r.tools} | {r.vision} | {r.reasoning} |")265    with open(args.out, "w") as handle:266        handle.write("\n".join(lines) + "\n")267    log(f"\n{len(results) - len(failed)}/{len(results)} green → {args.out}")268    sys.exit(1 if failed else 0)269270271if __name__ == "__main__":272    main()273