Files
2026-09-15 15:24:03 -04:00

570 lines
35 KiB
Python

"""Exercise every owuitools endpoint the way Open WebUI would.
Runs on openwebui_default and talks to the gateway by name, so it takes the
same path a real tool call does - through nginx, into the guarded
namespace - rather than testing the app in isolation where the firewall,
the proxy and the auth header would all be bypassed.
"""
import time
import json
import os
import sys
import textwrap
import httpx
BASE = os.environ.get("OWUITOOLS_BASE", "http://owuitools-gw:8000")
TOKEN = os.environ["OWUITOOLS_AUTH_TOKEN"]
MAX_CHARS = int(os.environ.get("OWUITOOLS_MAX_RESPONSE_CHARS", "12000"))
VERBOSE = "-v" in sys.argv
# (tool, payload, must_appear_in_response_or_None[, must_NOT_appear])
# The fourth element is optional and may be one string or a tuple of them.
# Several behaviours here are only testable as an absence - an NXDOMAIN that
# must not be reported as a policy denial, a corrected hint that must not
# still carry the wrong claim - and a missing negative reads as a pass.
CASES = [
# --- offline / deterministic: these must always pass -----------------
("subnet_calc", {"network": "192.168.1.0/24"}, "192.168.1.255"),
("subnet_calc", {"network": "10.0.0.0/8", "split_into": 4}, "10.64.0.0/10"),
("subnet_calc", {"network": "2001:db8::/32"}, "2001:db8::"),
("network_reference", {"lookup_type": "port", "value": "443"}, "https"),
("network_reference", {"lookup_type": "http_status", "value": "418"}, "teapot"),
("network_reference", {"lookup_type": "mac_vendor", "value": "00:1A:2B:3C:4D:5E"}, None),
("ohms_law_power", {"voltage": "12", "resistance": "220"}, "54.55 mA"),
("ohms_law_power", {"power": "0.5", "resistance": "100"}, None),
("component_calc", {"calculation": "resistor_color_code", "text": "brown black red gold"}, "1 kΩ"),
("component_calc", {"calculation": "resistor_to_color", "value": "4k7"}, "yellow"),
("component_calc", {"calculation": "nearest_e_series", "value": "3300", "series": "E24"}, None),
("component_calc", {"calculation": "rc_filter", "resistance": "10k", "capacitance": "100n"}, "159.2 Hz"),
("component_calc", {"calculation": "lc_resonance", "inductance": "10u", "capacitance": "100n"}, None),
("component_calc", {"calculation": "voltage_divider", "voltage": "12", "resistance": "10k", "resistance2": "2k2"}, None),
("component_calc", {"calculation": "series_parallel", "text": "10k, 10k", "combination": "parallel"}, "5 kΩ"),
("pcb_calc", {"calculation": "trace_width", "current": "2"}, "mm"),
("pcb_calc", {"calculation": "microstrip_impedance", "trace_width": "0.3", "dielectric_height": "0.2"}, "Ω"),
("pcb_calc", {"calculation": "via_current", "via_diameter": "0.3", "current": "1"}, None),
("wiring_calc", {"calculation": "awg_properties", "awg": 14}, "mm²"),
("wiring_calc", {"calculation": "voltage_drop", "awg": 12, "length": "30", "current": "10", "voltage": "12"}, "%"),
("wiring_calc", {"calculation": "led_resistor", "voltage": "5", "led_forward_voltage": "2.1", "led_current": "0.02"}, "145 Ω"),
("wiring_calc", {"calculation": "decibels", "value": "30", "db_from": "dBm"}, "1 W"),
("wiring_calc", {"calculation": "battery_life", "capacity_mah": 2000, "load_ma": 150}, "hours"),
# --- MATLAB sandbox -------------------------------------------------
# These assert the three verdicts the tool exists to distinguish:
# it worked, it is not valid MATLAB, and it failed for a reason that
# is not the model's fault.
("matlab_run", {"code": "x = linspace(0,1,5);\ndisp(sum(x))"}, "MATLAB run OK"),
("matlab_run", {"code": "a=[1 2 3];\nb=[1 2];\nc=a*b;"}, "nonconformant"),
("matlab_run", {"code": "a=[1 2 3];\nb=[1 2];\nc=a*b;"}, "main.m line 3"),
("matlab_run", {"code": "for i=1:2\n printf('%d',i);\nendfor"}, "NOT VALID MATLAB"),
# Was: "contains is real MATLAB but not available here". Change 58 made
# it available, so what is pinned now is that it runs and that nothing
# tells the model to rewrite it.
("matlab_run", {"code": "s = contains('abc','b');\ndisp(s)"}, "1", "not available"),
("matlab_run", {"code": "while true; end", "timeout_seconds": 3}, "Timed out"),
("matlab_run", {"code": "function r=f(a)\n r=a;\nend"}, "function definition, not a script"),
("matlab_run", {"code": "disp(addup(2,3))",
"helper_files": [{"name": "addup.m", "code": "function r=addup(a,b)\n r=a+b;\nend"}]}, "5"),
("matlab_run", {"code": "x = webread('http://example.com');"}, "no network"),
("matlab_run", {"code": ""}, "CANNOT DO THAT"),
# Regressions found by adversarial review; each of these was broken.
("matlab_run", {"code": "x=1;\n# double it\nx=x*2;\ndisp(x)"}, "NOT VALID MATLAB"),
("matlab_run", {"code": "plot_style = 'x';\ns = sprintf('a', ...\n '--r');\ndisp(1)"}, "MATLAB run OK"),
("matlab_run", {"code": "A=[1 1;1 1];\nx=A\\[1;2];\ndisp('done')"}, "Warnings"),
("matlab_run", {"code": "a = zeros(60000,60000);", "timeout_seconds": 30}, "out of memory"),
("matlab_run", {"code": "disp(addup(2,3))",
"helper_files": [{"name": "2bad.m", "code": "function r=addup(a,b)\n r=a+b;\nend"}]},
"Helper files ignored"),
# --- network-dependent ----------------------------------------------
("web_search", {"query": "RFC 9293 TCP specification", "max_results": 4}, None),
("web_search", {"query": "systemd-resolved DNSSEC failure", "category": "it", "max_results": 3}, None),
("fetch_page", {"url": "https://example.com", "max_chars": 1500}, "Example Domain"),
("wikipedia_lookup", {"topic": "OSI model"}, "layer"),
("search_stackexchange", {"query": "bash trap EXIT", "site": "unix", "max_results": 2}, None),
("lookup_rfc", {"number": 9293, "offset": 0}, "RFC 9293"),
("lookup_rfc", {"query": "border gateway protocol"}, "Proposed Standard"),
("lookup_cve", {"cve_id": "CVE-2021-44228"}, "Log4j"),
("read_man_page", {"name": "ip", "section": 8}, "NAME"),
("lookup_package", {"name": "requests", "ecosystem": "pypi"}, "Latest version"),
("lookup_package", {"name": "torvalds/linux", "ecosystem": "github"}, "tags"),
("dns_lookup", {"name": "cloudflare.com", "record_type": "A"}, "TTL"),
("dns_lookup", {"name": "google.com", "record_type": "MX"}, None),
("inspect_tls", {"hostname": "example.com"}, "Issuer"),
("http_headers", {"url": "https://example.com"}, "Security headers"),
("lookup_ip_or_asn", {"target": "8.8.8.8"}, None),
("find_datasheet", {"part_number": "LM358"}, None),
("search_papers", {"query": "GaN transistor switching losses", "max_results": 2}, None),
# --- guardrails: these must be REFUSED, not fulfilled ----------------
("fetch_page", {"url": "http://192.168.1.10:9443/"}, "CANNOT DO THAT"),
("fetch_page", {"url": "http://172.19.0.2:8080/api/config"}, "CANNOT DO THAT"),
("fetch_page", {"url": "file:///etc/passwd"}, "CANNOT DO THAT"),
("fetch_page", {"url": "http://localhost:8000/health"}, "CANNOT DO THAT"),
("dns_lookup", {"name": "router.local", "record_type": "A"}, "CANNOT DO THAT"),
("lookup_ip_or_asn", {"target": "192.168.1.1"}, "CANNOT DO THAT"),
("ohms_law_power", {"voltage": "12"}, "CANNOT DO THAT"),
("component_calc", {"calculation": "rc_filter", "resistance": "10k"}, "CANNOT DO THAT"),
# Models pass "" for optionals they chose not to use; must not 422.
("web_search", {"query": "test", "site": "", "language": "", "max_results": 3}, None),
("lookup_rfc", {"number": 1918, "query": ""}, "1918"),
# --- citation metadata: a page has to say what it is -----------------
# The URLs are stable documents chosen for their metadata, not their
# ranking: a paper of record on PLOS and the arXiv abstract of
# "Attention Is All You Need". Two cases per page where two separate
# fields are being asserted, the same way the MATLAB cases above do.
("fetch_page", {"url": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0173152",
"max_chars": 1200}, "CITE AS: Ravenscroft"),
("fetch_page", {"url": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0173152",
"max_chars": 1200}, "Journal: PLOS ONE"),
("fetch_page", {"url": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0173152",
"max_chars": 1200}, "DOI: 10.1371"),
# Comma-formatted authors are "Vaswani, Ashish"; the surname is the cite.
("fetch_page", {"url": "https://arxiv.org/abs/1706.03762", "max_chars": 900},
"CITE AS: Vaswani", "CITE AS: Ashish"),
# What arrived is not always what citing it would imply.
("fetch_page", {"url": "https://arxiv.org/abs/1706.03762", "max_chars": 700}, "abstract page"),
("fetch_page", {"url": "https://pubmed.ncbi.nlm.nih.gov/32939066/", "max_chars": 900},
"not the paper"),
("fetch_page", {"url": "https://example.com", "max_chars": 800},
"No author, journal or DOI metadata"),
# --- reading inside a document ---------------------------------------
("fetch_page", {"url": "https://www.rfc-editor.org/rfc/rfc9293.txt",
"search": "Retransmission Timeout", "max_chars": 1200}, "Jumped to"),
("fetch_page", {"url": "https://example.com", "search": "zzzqqqnotpresent"},
"does not occur in this document"),
# The continuation hint pushes on; it must not read as permission to stop.
("fetch_page", {"url": "https://www.rfc-editor.org/rfc/rfc9293.txt", "max_chars": 1000},
"Keep reading if the answer may be further in", "Only do so if this section did not answer"),
("lookup_rfc", {"number": 1035, "search": "2.3.4"}, "skipped"),
# --- stub and failure honesty: a non-answer must not look like one ---
("fetch_page", {"url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"},
"client-rendered or gated"),
# A name that does not exist is a DNS fact, not a firewall refusal; saying
# "private network" there sends the model looking for a way around it.
("fetch_page", {"url": "https://api.no-such-host-owuitools.invalid/x"},
"does not resolve", "private network"),
("inspect_tls", {"hostname": "no-such-host-owuitools.invalid"},
"does not resolve", "private network"),
# --- search relevance and category honesty ---------------------------
# An invented title returns something from every engine; the results are
# not about the query, and saying so is the whole point.
("web_search", {"query": '"Ferrous lattice resonance in dodecahedral copper" Whitlock Ambersley',
"max_results": 5}, "LOW RELEVANCE"),
("web_search", {"query": "RFC 9293 TCP retransmission timeout computation", "max_results": 4},
"Snippets are search-engine excerpts", "LOW RELEVANCE"),
("web_search", {"query": "amoxicillin adult dose sinusitis mg how much should i take",
"max_results": 4}, "dailymed"),
("web_search", {"query": "ibuprofen maximum daily dose mg adult", "category": "science",
"max_results": 4}, "scholarly indexes only"),
# Structural, not about which papers rank: some scholarly result carries
# enough metadata to cite. Only a dead scholarly index breaks this.
("web_search", {"query": "metformin lactic acidosis cohort study", "category": "science",
"max_results": 6}, "CITE AS:"),
# --- wikipedia resolution --------------------------------------------
("wikipedia_lookup", {"topic": "Mercury"}, "disambiguation page"),
("wikipedia_lookup", {"topic": "Marguerite Delacroix-Whitmore"}, "NO ARTICLE"),
# An exact title must win over whatever full-text search would have found.
("wikipedia_lookup", {"topic": "Canberra"}, "# Canberra"),
("wikipedia_lookup", {"topic": "Canberra", "full_article": True,
"search": "Federal Capital Territory"}, "Jumped to"),
("wikipedia_lookup", {"topic": "Eiffel Tower"}, "Introduction only"),
# --- v3: a citation the answer can lift ------------------------------
# Two graded rounds: 1 of 25 tool-assisted answers carried a URL, and the
# one that did lifted a ready-made CITE AS line. Every tool that returns a
# single identifiable document now ends with the line the answer wants.
("fetch_page", {"url": "https://example.com", "max_chars": 800},
"\n---\nSources: https://example.com"),
("wikipedia_lookup", {"topic": "Canberra"},
"\n---\nSources: https://en.wikipedia.org/wiki/Canberra"),
("lookup_rfc", {"number": 1035, "offset": 0}, "\n---\nSources: RFC 1035"),
("read_man_page", {"name": "ls"}, "\n---\nSources: man ls"),
("lookup_package", {"name": "requests", "ecosystem": "pypi"},
"\n---\nSources: https://"),
# A paper carries its citation as well as its URL.
("fetch_page", {"url": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0173152",
"max_chars": 1200}, "Sources: Ravenscroft"),
# Still there at the largest window the schema allows, where the page
# budget is tightest. (That `cap()` re-attaches it after trimming is a
# pure-function property; this asserts the room reservation upstream.)
("fetch_page", {"url": "https://www.rfc-editor.org/rfc/rfc9293.txt", "max_chars": 10000},
"\n---\nSources: https://www.rfc-editor.org/rfc/rfc9293.txt"),
# --- v3: a jump says which section it landed in -----------------------
# "Constitution" inside the Canberra article matched "1 Constitution
# Avenue" - a street - and the model read the window as having checked the
# constitutional basis for the capital. A character offset cannot say you
# are in the wrong subject; the enclosing heading can.
("wikipedia_lookup", {"topic": "Canberra", "full_article": True, "search": "Constitution"},
"inside the section"),
("lookup_rfc", {"number": 1035, "search": "63 octets"}, "inside the section"),
("read_man_page", {"name": "ls", "search": "--almost-all"}, "inside the section"),
# --- v3: a search miss does not throw the document away ---------------
# One search string missing is not the same finding as the document being
# useless; returning only "not here" made the two indistinguishable, and
# the model abandoned 54,000 characters of the right article.
("wikipedia_lookup", {"topic": "Sense", "full_article": True, "search": "zzzqqqnotpresent"},
"Showing the opening of the document instead"),
("lookup_rfc", {"number": 1035, "search": "zzzqqqnotpresent"},
"Showing the opening of the document instead"),
("read_man_page", {"name": "ls", "search": "zzzqqqnotpresent"},
"Showing the opening of the document instead"),
# --- v3: which of your terms is absent, as a count --------------------
# A model hunting a half-remembered paper puts its own guesses in the
# query; topically plausible results then read as partial confirmation.
("web_search", {"query": "Korhonen coffee randomized trial type 2 diabetes Circulation 2018",
"max_results": 6}, "Appears in none of these results"),
("web_search", {"query": "linux kernel scheduler documentation", "category": "it",
"max_results": 3}, None, "Appears in none of these results"),
# An index listing is not the document, and cannot be quoted as one.
("lookup_rfc", {"query": "border gateway protocol"}, "These are index entries"),
# --- per-tool additions ----------------------------------------------
("http_headers", {"url": "https://github.com"}, "max-age="),
# Splitting past /64 breaks SLAAC, which the split is silent about otherwise.
("subnet_calc", {"network": "2001:db8:1::/64", "split_into": 4}, "RFC 7421"),
# --- Change 55: the v3 round's tool defects ---------------------------
# A documentation address is named as such, with its RFC, and a public
# address carries its origin AS from the routing view, not just its
# registration.
("lookup_ip_or_asn", {"target": "203.0.113.10"}, "RFC 5737"),
("lookup_ip_or_asn", {"target": "8.8.8.8"}, "Origin AS"),
("subnet_calc", {"network": "203.0.113.0/24"}, "reserved for documentation"),
# Board dimensions with units, and no negative impedance ever printed.
("pcb_calc", {"calculation": "microstrip_impedance", "trace_width": "12mil", "dielectric_height": "8 mil"}, "0.305 mm"),
("pcb_calc", {"calculation": "microstrip_impedance", "trace_width": "0.3mm", "dielectric_height": "1.6mm"}, "Ω", "≈ -"),
("pcb_calc", {"calculation": "microstrip_impedance", "trace_width": "10", "dielectric_height": "0.1"}, "CANNOT DO THAT", "Ω**"),
# A search miss on an unsectioned name checks the sibling sections.
("read_man_page", {"name": "crontab", "search": "PATH"}, "section 5"),
# PMC and PubMed pages are served from the Europe PMC open-access API.
("fetch_page", {"url": "https://pubmed.ncbi.nlm.nih.gov/24459154/", "max_chars": 900}, "Retrieved via"),
("fetch_page", {"url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC3898757/", "max_chars": 900}, "Diabetes Care"),
# A PDF whose text layer is glyph codes is refused, not served as text.
("fetch_page", {"url": "https://www.infineon.com/assets/row/public/documents/24/49/infineon-irlz44n-datasheet-en.pdf",
"max_chars": 900}, "not decodable"),
# An oversized window is clamped; a bad parameter is prose, not a 422.
("fetch_page", {"url": "https://example.com", "max_chars": 10716}, "Example Domain", "422"),
("fetch_page", {"url": "https://example.com", "offset": -5}, "CANNOT DO THAT"),
# A page-numberless contents entry (RFC v3 text format) is stepped over.
("lookup_rfc", {"number": 9110, "search": "15.5.19"}, "skipped 1 table-of-contents"),
("lookup_rfc", {"number": 9110, "search": "15.5.19"}, "15.5. Client Error 4xx"),
# Parse-error and ordering hints.
("matlab_run", {"code": "x = 2;\nfprintf('today\\'s value: %d\\n', x);", "purpose": "scratch"}, "two quotes"),
# Was: the hint explaining that Octave cannot see a function defined
# below its call. Change 58 lifts those out instead, so the script the
# hint was written for now simply runs; the hint survives for the files
# the lift refuses, and is checked by tests/test_local_functions.py.
("matlab_run", {"code": "disp(addup(2,3))\nfunction r = addup(a,b)\n r = a+b;\nend", "purpose": "scratch"},
"5", "below the line that calls it"),
("subnet_calc", {"network": "192.168.1.0/24", "split_into": 4}, "192.168.1.192", "RFC 7421"),
("pcb_calc", {"calculation": "microstrip_width_for_impedance", "target_impedance": "50",
"dielectric_height": "0.2"}, "Width for"),
# IPC-2152 is not a licence to narrow an external trace, which is what the
# old wording invited.
("pcb_calc", {"calculation": "trace_width", "current": "3"},
"not a way to shrink an external trace", "usually less conservative standard if you need to squeeze"),
("read_man_page", {"name": "ip route"}, "ip-route", "No manual page found"),
# Both labels come off the vendor-pattern URL, not off search ranking:
# LM358DR strips to LM358, so the official PDF is a variant, not the part.
("find_datasheet", {"part_number": "LM358"}, "[EXACT LM358]"),
("find_datasheet", {"part_number": "LM358DR"}, "VARIANT: names 'LM358'"),
("lookup_cve", {"cve_id": "CVE-2024-6387"}, "openssh: before"),
("lookup_cve", {"cve_id": "CVE-2021-44228"}, "CISA KEV"),
("matlab_run", {"code": "n = numel(regexp('a b', '\\bb', 'match'));\ndisp(n)"}, "BACKSPACE"),
# purpose='scratch' is the model working for itself: give it the Octave
# equivalent and drop the MATLAB-validity verdict, which is not the job.
# `contains` is a shim now (Change 58) and runs, so the equivalent-advice
# path is tested with a name that is still missing.
("matlab_run", {"code": "s = split('a b');\ndisp(s)", "purpose": "scratch"},
"strsplit", "Do NOT rewrite"),
("matlab_run", {"code": "x=1;\n# double it\nx=x*2;\ndisp(x)", "purpose": "scratch"},
None, "NOT VALID MATLAB"),
# --- Change 56: the v4 round's easy wins ---------------------------------
# A cable length in feet is feet, not femtometres.
("wiring_calc", {"calculation": "voltage_drop", "awg": 12, "length": "100ft", "current": "20",
"voltage": "120"}, "30.48 m (100 ft)", ("fV", "1e-13")),
("wiring_calc", {"calculation": "voltage_drop", "awg": 12, "length": "100 ft", "current": "20",
"voltage": "120"}, "5.29%"),
# A bare "30m" is thirty metres, not thirty milli-anything.
("wiring_calc", {"calculation": "voltage_drop", "awg": 6, "length": "30m", "current": "20",
"voltage": "12"}, "over 30 m of"),
("wiring_calc", {"calculation": "voltage_drop", "awg": 6, "length": "100k", "current": "20",
"voltage": "12"}, "Could not read"),
("wiring_calc", {"calculation": "three_phase_drop", "awg": 4, "length": "250 ft", "current": "40",
"voltage": "480", "power_factor": 0.85}, "76.2 m (250 ft)"),
# The width at the other copper weight, so "2 oz saves 40%" is not worked by hand.
("pcb_calc", {"calculation": "trace_width", "current": "3", "temperature_rise": 10}, "At 2 oz copper"),
("pcb_calc", {"calculation": "trace_width", "current": "3", "temperature_rise": 10}, "26.9 mil"),
# One lint entry per rule, with the line numbers, not one per line.
("matlab_run", {"code": 'x = "a";\ny = "b";\nz = "c";\ndisp(1)', "purpose": "deliverable"},
"(3 places)"),
# The std/var weight flag, stated the right way round.
("matlab_run", {"code": "s = std([1 2 3], 0, 1);\ndisp(s)", "purpose": "scratch"}, "N-1"),
("matlab_run", {"code": "s = std([1 2 3]);\ndisp(s)", "purpose": "scratch"}, None, "weight flag"),
# A topic-shaped query falls back to its phrases instead of NO RESULTS.
("lookup_rfc", {"query": "DNS domain name maximum length 63 octets label"}, "RFC 1035"),
("lookup_rfc", {"query": "DNS domain name maximum length 63 octets label"}, "these titles contain"),
# Icon-only anchors keep their href, so an index page still leads somewhere.
("fetch_page", {"url": "https://investors.raspberrypi.com/reports", "max_chars": 6000},
"investors.raspberrypi.com/reports/1"),
# A byline's credential is not a co-author.
("fetch_page", {"url": "https://www.poison.org/articles/acetaminophen-take-it-safely",
"max_chars": 1200}, "Johnson-Arbor,", "Johnson-Arbor et al."),
# A site's own name is not a Dr com.
("fetch_page", {"url": "https://www.electricallicenserenewal.com/Electrical-Continuing-Education-Courses/NEC-Content.php?sectionID=808",
"max_chars": 1200}, None, "CITE AS: com,"),
# --- Change 58: closer to MATLAB ----------------------------------------
# A script that calls its local functions before defining them is
# ordinary MATLAB and used to fail here with "'double_it' undefined".
("matlab_run", {"code": "x = 3;\ndisp(double_it(x));\n\n"
"function y = double_it(v)\n y = 2 * v;\nend\n"},
"6", "undefined"),
# An error inside one of those functions still reports the line number
# of the model's own script, not of the file we made from it.
("matlab_run", {"code": "v = [1 2 3];\ndisp(pick(v));\n\n"
"function y = pick(x)\n y = x(9);\nend\n"},
"main.m line 5 (in pick)"),
# The shims: each is a MATLAB function this Octave does not have.
("matlab_run", {"code": "disp(contains('hello world', 'o w'));\n"
"disp(strlength({'a','abc'}));\n"
"disp(maxk([3 1 4 1 5], 2));\n"
"disp(histcounts([1 2 2 3], [1 2 3 4]));\n"
"disp(rmmissing([1 NaN 3]));",
"purpose": "scratch"}, "1 2 1", "undefined"),
# histcounts without edges refuses rather than inventing a binning rule.
("matlab_run", {"code": "disp(histcounts([1 2 3]));", "purpose": "scratch"},
"automatic bin selection is not reproduced"),
# MATLAB call forms this Octave does not accept, named as such.
("matlab_run", {"code": "disp(round(pi, 3));", "purpose": "scratch"}, "round(x * 10^n)"),
("matlab_run", {"code": "disp(sum(magic(3), 'all'));", "purpose": "scratch"}, "sum(A(:))"),
# Octave's own parser, reporting what no regex rule covers.
("matlab_run", {"code": "x = 5;\nx .*= 2;\ndisp(x)\n"}, "Octave's parser read"),
# A function named after a MATLAB built-in shadows it in the user's MATLAB.
("matlab_run", {"code": "disp(normalize([1 2]));\n\n"
"function y = normalize(x)\n y = x / max(x);\nend\n"},
"Shadows a MATLAB function"),
# Display and file format, two preferences set in the driver.
("matlab_run", {"code": "x = []", "purpose": "scratch"}, "x = []", "[](0x0)"),
("matlab_run", {"code": "x = 1;\nsave('d.mat', 'x');\n"
"f = fopen('d.mat','r'); h = fread(f, 24, 'char=>char')'; fclose(f);\n"
"disp(strtrim(h));", "purpose": "scratch"},
"MATLAB 5.0 MAT-file", "Created by Octave"),
# --- Change 57: plots that are not written on top of themselves ----------
# A figure comes back as a data URI whose first bytes ARE the assertion:
# PNG puts width and height at a fixed offset, so the base64 prefix below
# is exactly "1100 x 825" and nothing else. It fails if the shim is off
# the path, if print goes back to scaling type with -S, or if the canvas
# changes size - the three ways this rendering has broken.
("matlab_run", {"code": "t = linspace(0, 1, 200);\n"
"plot(t, sin(2*pi*t), 'LineWidth', 1.4);\n"
"title('Smoke test');\n"
"xlabel('Time (s)'); ylabel('Amplitude');\n"
"legend('one cycle');\n"
"print('plot.png', '-dpng');",
"return_figure": "plot.png", "purpose": "scratch"},
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABEwAAAM5"),
# The shape follows the figure the script asked for: 1200x400 on screen
# is 1100x440 on paper, not a 4:3 picture with the plot squashed into it.
("matlab_run", {"code": "figure('position', [0 0 1200 400]);\n"
"plot(1:10, (1:10).^2);\n"
"print('plot.png', '-dpng');",
"return_figure": "plot.png", "purpose": "scratch"},
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABEwAAAG4"),
# --- the VM sub-agent -------------------------------------------------
# Only the refusal paths belong in this suite. A real session starts a
# machine, runs a model with thinking on, and takes minutes to hours -
# far past run_case's 90 s - so the end-to-end case lives in its own
# opt-in script. What is checked here is that every way of asking for
# something impossible comes back as prose the model can act on rather
# than as a traceback, a 500, or a silent success.
("vm_agent", {"task": "run the build and report what happens",
"os": "ubuntu", "interface": "cli", "project": "../gitea"},
"CANNOT DO THAT"),
("vm_agent", {"task": "run the build and report what happens",
"os": "ubuntu", "interface": "cli", "project": "Bad_Name"},
"CANNOT DO THAT"),
("vm_agent", {"task": "run the build and report what happens",
"os": "plan9", "interface": "cli", "project": "smoke"},
"CANNOT DO THAT"),
# `interface` is VESTIGIAL and is ignored - the cli/gui split was removed
# when every session got the whole toolbox, and vm.py hardcodes "gui".
# This case used to pass a nonsense interface with a VALID project and
# expect a refusal, which stopped being true when the parameter stopped
# being read: the request became a perfectly good one and STARTED A REAL
# SESSION inside the smoke suite, which then died on run_case's 90 s
# timeout with a transport error. Measured 2026-09-15; the session was
# cancelled correctly by the disconnect path, so it left nothing behind
# and the only visible symptom was one confusing failure.
#
# The case is still worth having - a stray parameter must be tolerated,
# not 500 - so it keeps the nonsense interface and pairs it with a
# project name that is refused outright. Now it asserts both things and
# cannot start anything.
("vm_agent", {"task": "run the build and report what happens",
"os": "ubuntu", "interface": "telepathy", "project": "Bad_Name"},
"CANNOT DO THAT"),
# A task too short to act on: the agent cannot see the conversation, so
# "do it" is not a task, it is a guess waiting to happen.
("vm_agent", {"task": "fix it", "os": "ubuntu", "interface": "cli"},
"CANNOT DO THAT"),
# The pre-flight that exists because guessing the machine wrong costs a
# whole session: a Python task with no OS named in it must come back as a
# question for the user, not as a started VM.
("vm_agent", {"task": "write a python script that prints the first 20 primes and run it",
"os": "ubuntu", "interface": "cli", "project": "smoke"},
"CANNOT DO THAT YET"),
# The mirror of that - a task that DOES name its machine and must be let
# through - is deliberately not here: letting it through starts a real
# VM session, which is minutes long by design and would hang this suite.
# It is covered in tests/test_vm_preflight.py instead, against the
# function rather than over HTTP.
("vm_agent", {"cancel": True, "session_id": "not-a-session",
"task": "x" * 20, "os": "ubuntu", "interface": "cli"},
"CANNOT DO THAT"),
# A tool result that IS a data: URI is handed to the model as a vision
# input by Open WebUI, and budget.cap() passes it through untouched. A
# guest could otherwise choose a picture for the main model to look at.
("vm_agent", {"task": "run the build and report what happens",
"os": "ubuntu", "interface": "cli", "project": "../gitea"},
None, "data:image"),
# Nothing in a refusal should ever carry a credential or a host path.
("vm_agent", {"task": "run the build and report what happens",
"os": "plan9", "interface": "cli", "project": "smoke"},
None, ("GITEA_TOKEN", "Bearer", "/var/lib/owuivm", "Traceback")),
]
# The suite above sends no chat id, on purpose: note_fetch no-ops without one,
# so the per-conversation page budget never touches those cases and the order
# they run in cannot matter. The budget itself is only observable across an
# ordered sequence sharing one chat id, so these four run last, with that
# header attached per request rather than set on the client - it cannot leak
# back into anything above, and a re-run gets a fresh conversation.
FETCH_BUDGET_CASES = [
("fetch_page", {"url": "https://www.rfc-editor.org/rfc/rfc8446.txt", "max_chars": 800},
None, "STOP FETCHING"),
# Reading further into a page already open is free: same URL, new window.
("fetch_page", {"url": "https://www.rfc-editor.org/rfc/rfc8446.txt", "offset": 5000,
"max_chars": 800}, "characters 5,000-", "STOP FETCHING"),
("fetch_page", {"url": "https://api.no-such-host-owuitools.invalid/y"}, "does not resolve"),
# The same dead URL a second time is declined from memory, not re-attempted.
("fetch_page", {"url": "https://api.no-such-host-owuitools.invalid/y"}, "not retried"),
]
def run_case(client, case, failures, oversized, headers=None) -> bool:
"""Post one case, print its one-line result, return whether it passed."""
tool, payload, expect = case[:3]
forbid = case[3] if len(case) > 3 else None
forbidden = [forbid] if isinstance(forbid, str) else list(forbid or ())
label = f"{tool}({', '.join(f'{k}={v!r}' for k, v in list(payload.items())[:2])})"
try:
response = client.post(f"/tools/{tool}", json=payload, headers=headers)
except Exception as exc:
failures.append(f"{label}: transport error {type(exc).__name__}: {exc}")
print(f"FAIL {label}\n transport {type(exc).__name__}")
return False
if response.status_code != 200:
failures.append(f"{label}: HTTP {response.status_code} {response.text[:200]}")
print(f"FAIL {label}\n HTTP {response.status_code}: {response.text[:160]}")
return False
body = response.json()
if not isinstance(body, str):
failures.append(f"{label}: returned {type(body).__name__}, expected a string")
print(f"FAIL {label}\n returned {type(body).__name__}, not str")
return False
if not body.strip():
failures.append(f"{label}: empty body")
print(f"FAIL {label}\n empty response")
return False
# A figure is a data: URI, and a picture is allowed to be bigger than a
# page of prose: Open WebUI routes it to the image slot, not the text the
# cap exists to protect.
if len(body) > MAX_CHARS + 400 and not body.startswith("data:"):
oversized.append(f"{label}: {len(body)} chars")
if expect is not None and expect.lower() not in body.lower():
failures.append(f"{label}: expected {expect!r} in response; got: {body[:220]}")
print(f"FAIL {label}\n expected {expect!r}\n got: {body[:200]!r}")
return False
if present := [f for f in forbidden if f.lower() in body.lower()]:
failures.append(f"{label}: must not say {present!r}; got: {body[:220]}")
print(f"FAIL {label}\n must not contain {present!r}\n got: {body[:200]!r}")
return False
print(f"ok {label} [{len(body):,} chars]")
if VERBOSE:
print(textwrap.indent(body[:600], " | "))
return True
def main() -> int:
client = httpx.Client(
base_url=BASE,
headers={"Authorization": f"Bearer {TOKEN}",
# Unique per run: the probe limiter is per user per hour, and
# running this suite four times in an hour otherwise exhausts
# the budget and reports three real tools as broken.
"X-OpenWebUI-User-Id": f"smoketest-{os.getpid()}-{int(time.time())}",
"X-OpenWebUI-User-Email": "smoke@test",
"X-OpenWebUI-User-Role": "user"},
timeout=90.0,
)
passed = failed = 0
failures = []
oversized = []
for case in CASES:
ok = run_case(client, case, failures, oversized)
passed, failed = (passed + 1, failed) if ok else (passed, failed + 1)
# One chat id, this run only, for the ordered budget sequence. It goes on
# the requests rather than the client so nothing above can pick it up.
chat_headers = {"X-OpenWebUI-Chat-Id": f"smoketest-{os.getpid()}-{int(time.time())}"}
print("\n--- fetch budget: ordered, sharing one chat id ---")
for case in FETCH_BUDGET_CASES:
ok = run_case(client, case, failures, oversized, headers=chat_headers)
passed, failed = (passed + 1, failed) if ok else (passed, failed + 1)
print()
print("=" * 68)
print(f"passed {passed} / {passed + failed}")
if oversized:
print(f"\nOVER SIZE CAP ({MAX_CHARS} chars) — these would eat the context window:")
for item in oversized:
print(" ", item)
if failures:
print("\nFAILURES:")
for item in failures:
print(" -", item)
return 1
print("\nAll tools responded correctly and stayed within the size cap.")
return 0
if __name__ == "__main__":
sys.exit(main())