Files
python-sdk/hanzo.yml
zeekay 4998a916fd the client sends the credential the document now declares
hanzoai/cloud@113cf7d6 declares components.securitySchemes.bearer and applies it
document-wide, so regenerating writes a populated Configuration.auth_settings()
and auth_settings=['bearer'] into 2498 call sites; ApiClient._apply_auth_params
sets Authorization: Bearer <token> from access_token, which was read by nothing
before. Four call sites carry an empty list — exactly the operations the document
marks security: [] (get_models, get_models_providers, get_commands,
get_openapi_json) — so the public routes still go out with no credential.

examples/client.py drops the header_name/header_value compensation and passes
access_token. examples/models is a new flow on the public catalog: it runs before
a reader has a key, which makes the install checkable in one command.

Proven on api.hanzo.ai, not by reading: examples.models prints 112 models with no
credential; examples.hello returns the caller's keys with a real sk- and 403
"sign in to manage API keys" with a bogus one.
2026-08-14 17:22:30 -07:00

207 lines
10 KiB
YAML

# Hanzo CI — the ONE way this repo is gated, read by hanzoai/ci.
#
# No `images:` and no `deploy:`: these are libraries. A consumer installs them
# from PyPI at a tag, so what CI owes is a gate, not an artifact. Publishing
# stays where it already is — .hanzo/workflows/publish-pypi.yml on our own
# runners, which is the canonical path because it reads the PyPI token from KMS
# like every other publish credential in the fleet. Adding a second publish here
# would be one too many; this file only gates.
#
# Scope is the generated cloud client and the flows that exercise it. The other
# 64 packages under pkg/ are hand-written and carry their own tests; pulling
# them in here would make a red gate mean "something, somewhere" instead of
# "the client the spec just produced is broken".
test:
- name: cloud-client
# Import every generated module. For generated code this IS the build step:
# there is no compiler to catch a bad $ref or a model that references a class
# the generator declined to emit — the import is what turns those into a
# failure instead of an AttributeError in a user's app six months later.
#
# IT DOES NOT CATCH A NAME COLLISION, and this comment used to claim it did.
# A class body that declares the same field twice is valid Python: the second
# binding replaces the first and the module imports clean. 3.2.0 shipped to
# PyPI that way — O11yGettableAgentCheckIn declared `integration_config` and
# `removed_at` twice each, so the agent's value was read and dropped — and
# this gate was green the whole time. `duplicate-fields` below is what sees
# it, because it reads the syntax tree rather than the import.
#
# The arc runner image promises no interpreter, so provision one with uv
# when it is missing. A gate that silently no-ops is worse than no gate.
run: |
set -e
if command -v uv >/dev/null 2>&1; then UV=uv; else
curl -fsSL https://astral.sh/uv/install.sh | sh
UV="$HOME/.local/bin/uv"
fi
PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \
--with pydantic --with python-dateutil --with urllib3 --with typing-extensions \
python -c '
import pkgutil, importlib, sys
import hanzoai.cloud.api as A, hanzoai.cloud.models as M
bad = []
for pkg, label in ((A, "api"), (M, "models")):
n = 0
for m in pkgutil.iter_modules(pkg.__path__):
try:
importlib.import_module(f"{pkg.__name__}.{m.name}"); n += 1
except Exception as e:
bad.append(f"{pkg.__name__}.{m.name}: {e!r}")
print(f"{label}: {n} modules imported")
for b in bad[:20]: print("FAIL", b, file=sys.stderr)
sys.exit(1 if bad else 0)
'
- name: duplicate-fields
# Read the syntax tree, because the import above cannot see this.
#
# `x: int` twice in one class body is legal Python. The second annotated
# assignment rebinds the name, the first is gone, and nothing complains —
# not the interpreter, not pydantic, not the import gate. The model then
# advertises a field it does not have, and the value that arrives on the
# wire under the shadowed name is read and dropped in silence. That is a
# data-loss bug that presents as no bug at all.
#
# This is not hypothetical and it is not rare: it is what the generator does
# every time two distinct spec properties normalise to one Python
# identifier. hanzoai 3.2.0 is on PyPI with exactly that — two collisions in
# O11yGettableAgentCheckIn — and the gate above was green for it.
#
# Stdlib `ast` only, no deps: a scan that needs an install is a scan that
# can fail to run for reasons unrelated to the code it is reading.
run: |
set -e
if command -v uv >/dev/null 2>&1; then UV=uv; else
curl -fsSL https://astral.sh/uv/install.sh | sh
UV="$HOME/.local/bin/uv"
fi
"$UV" run --no-project --python 3.12 python -c '
import ast, pathlib, sys
root = pathlib.Path("pkg/hanzoai/cloud")
hits, n = [], 0
for p in sorted(root.rglob("*.py")):
n += 1
try:
tree = ast.parse(p.read_text(encoding="utf-8", errors="replace"))
except SyntaxError as e:
hits.append("%s: does not parse: %r" % (p, e))
continue
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
seen = {}
for s in node.body:
# AnnAssign is `name: Type = ...`, how every field is declared
if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name):
if not s.target.id.startswith("__"):
seen.setdefault(s.target.id, []).append(s.lineno)
for f, lines in sorted(seen.items()):
if len(lines) > 1:
hits.append("%s: %s.%s declared %d times, lines %s"
% (p, node.name, f, len(lines), lines))
print("duplicate-fields: scanned %d generated modules" % n)
# A zero here must mean "looked and found nothing", never "looked at
# nothing". If the tree moves, this fails loudly instead of passing empty.
if n == 0:
print("FAIL scanned no modules under", root, file=sys.stderr)
sys.exit(1)
for h in hits:
print("FAIL", h, file=sys.stderr)
sys.exit(1 if hits else 0)
'
- name: examples
# The canonical flows, imported against the client that was just generated.
# Each `from hanzoai.cloud import X` is an assertion that the symbol still
# exists, so a spec change that renames or drops a MODEL or an API class
# goes red here rather than in a user's app.
#
# Importing is enough for the CLASS names BECAUSE each flow guards its call
# behind `if __name__ == "__main__"` — the module body resolves every name
# without opening a socket, so the gate needs no API key and no network.
#
# It is NOT enough for the METHOD names, and that gap has shipped twice. An
# import resolves the `from … import` line and nothing else; a method is
# looked up at call time, so a renamed OPERATION passes an import and fails
# in a user's app. Measured: when the `<svc>_` prefix left every operationId,
# four flows failed on their imports and `money` and `tools` went on passing
# while every call in them named a method that no longer existed — the gate
# saw four of six.
#
# So the check below reads the syntax tree for attribute accesses on anything
# bound to a `*Api` and asks the class whether it has them. Ten names resolve
# today. The receiver is resolved through its assignment, so it catches
# `KeysApi.get_tools` as readily as a stale `get_v1_tools`.
#
# `chat` is NOT in this list, and its absence is a measurement rather than a
# decision. cloud declares POST /v1/chat/completions with no `requestBody`
# and no `responses` at all, so the generated method takes no body and
# returns None: the one call a chat example exists to make cannot be
# expressed. Inventing the type here, or hand-rolling the HTTP inside a
# generated client, is the exact drift these SDKs exist to prevent, and
# hanzoai/js-sdk dropped its own chat flow at 2.0.7 for this same reason.
# Restoring it is one step and the test is one line: when
# paths['/v1/chat/completions'] in cloud's openapi.yaml carries a
# requestBody, add examples/chat back and put "chat" back in the tuple.
run: |
set -e
if command -v uv >/dev/null 2>&1; then UV=uv; else
curl -fsSL https://astral.sh/uv/install.sh | sh
UV="$HOME/.local/bin/uv"
fi
PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \
--with pydantic --with python-dateutil --with urllib3 --with typing-extensions \
python -c '
import ast, compileall, importlib, pathlib, sys
import hanzoai.cloud as C
ok = compileall.compile_dir("examples", quiet=1, maxlevels=3)
bad = []
for flow in ("models", "hello", "money", "store", "agent", "tools"):
try:
importlib.import_module(f"examples.{flow}.__main__")
except Exception as e:
bad.append(f"examples.{flow}: {e!r}")
continue
tree = ast.parse(pathlib.Path(f"examples/{flow}/__main__.py").read_text())
bound = {}
for n in ast.walk(tree):
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
and isinstance(n.value.func, ast.Name) and n.value.func.id.endswith("Api")):
bound.update({t.id: n.value.func.id for t in n.targets if isinstance(t, ast.Name)})
names = 0
for n in ast.walk(tree):
if not isinstance(n, ast.Attribute):
continue
v = n.value
cls = bound.get(v.id) if isinstance(v, ast.Name) else (
v.func.id if isinstance(v, ast.Call) and isinstance(v.func, ast.Name)
and v.func.id.endswith("Api") else None)
if not cls:
continue
names += 1
if not hasattr(getattr(C, cls), n.attr):
bad.append(f"examples.{flow}: {cls} has no {n.attr}")
print(f"examples.{flow}: OK, {names} method name(s) resolved")
for b in bad: print("FAIL", b, file=sys.stderr)
sys.exit(1 if (bad or not ok) else 0)
'
# THE CLIENT LANE — this repo is a PROJECTION of one API document at one version.
#
# hanzoai/cloud's release sends `repository_dispatch: spec-update` carrying
# (version, sha, spec_sha256); hanzoai/ci fetches openapi.yaml AT THAT SHA,
# REFUSES if the bytes hash to anything else, runs `generate:`, writes
# `.spec-lock` beside the code, and — only after the `test:` block below has
# passed over exactly those bytes — commits and cuts a patch.
#
# The document moved from hanzoai/openapi `hanzo.yaml` (hand-merged, 1742 paths,
# fed by nothing, and NOTHING HAS EVER SENT the spec-update this repo listens
# for) to hanzoai/cloud `openapi.yaml` — emitted from the code by each app's own
# router, and gated on every cloud release. It was 1058 paths when it took over
# and is 1814 now, which is the point: it grows from the routers rather than from
# an editor, so a true document overtook the unverified one on its own.
client:
generate: ./scripts/generate.sh
version: "pyproject.toml:grep -m1 '^version' pyproject.toml | sed -E 's/.*\"(.*)\".*/\\1/'"