Files
StyleRef-styleref-comfyui/styleref_api.py
T
BehzadandClaude Sonnet 5 29c9edd562 1.0.1 — model-agnostic description, working icon
Two things wrong on the Registry listing.

The description promised "reuse it across FLUX, SDXL, and SD1.5", which reads
as though the pack only targets those three. It is model-agnostic: a style
compiles for whatever target you pick, and FLUX and Z-Image are the models the
shipped templates happen to demo. Now matches the GitHub repo description. The
same model-specific phrasing is out of the README opening and the module
docstring; the Targets section still names models, which is factual there.

The icon pointed at https://styleref.io/icon.png, which 404s — hence the broken
image on the listing. Points at the 512x512 StyleRef mark instead. Its
background is opaque white rather than transparent, so a black mark stays
legible on the Registry's dark UI.

Metadata is read from the published version, so a release is the only way for
either fix to reach the listing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:28:18 +02:00

424 lines
15 KiB
Python

"""
Thin client for the StyleRef public REST API v1 (https://styleref.io/api/v1).
Stdlib-only on purpose: ComfyUI installs are wildly varied and a custom node
that drags in dependencies is a node that fails to import on someone's portable
build. `urllib` is enough for JSON and form posts.
The server owns all prompt logic — this client never compiles or rewrites a
style, it only fetches what the server produced. OpenAPI: /api/v1/openapi.json
"""
from __future__ import annotations
import contextlib
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
DEFAULT_API_BASE = "https://styleref.io/api/v1"
SITE_URL = "https://styleref.io"
# Identifies this surface in StyleRef's API telemetry (recordApiCall reads it).
CLIENT_TAG = "comfyui"
USER_AGENT = "styleref-comfyui/1.0.1 (+https://styleref.io)"
DEFAULT_TIMEOUT_S = 30
def api_base() -> str:
"""Env override exists so the nodes can be pointed at a local dev server."""
return (os.environ.get("STYLEREF_API") or "").strip() or DEFAULT_API_BASE
def site_origin_from_api_base(base: str) -> str:
parsed = urllib.parse.urlparse(base)
return f"{parsed.scheme}://{parsed.netloc}"
class StyleRefError(Exception):
"""
An API call that failed in a way the user should see.
`message` is safe to render verbatim in a node — the server writes its 402
and 401 bodies for humans (upgrade copy, sign-in copy), and rewording them
in the client would mean maintaining that copy in two places.
"""
def __init__(
self,
message: str,
status: int | None = None,
code: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.status = status
self.code = code
self.payload = payload or {}
@property
def needs_login(self) -> bool:
return self.status == 401
@property
def needs_credits(self) -> bool:
return self.status == 402
def encode_ref(ref: str) -> str:
"""URL-encode a style ref into one path segment (handles /share URLs)."""
return urllib.parse.quote(ref.strip(), safe="")
def build_style_url(
base: str,
ref: str,
fmt: str | None = None,
sections: str | None = None,
compact: bool = False,
raw: bool = False,
) -> str:
url = f"{base.rstrip('/')}/styles/{encode_ref(ref)}"
params: dict[str, str] = {}
if fmt and fmt != "default":
params["format"] = fmt
if sections:
params["sections"] = sections
if compact:
params["compact"] = "1"
if raw:
params["raw"] = "1"
return f"{url}?{urllib.parse.urlencode(params)}" if params else url
def build_search_url(
base: str,
query: str = "",
category: str | None = None,
sort: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> str:
params: dict[str, str] = {}
if query.strip():
params["query"] = query.strip()
if category:
params["category"] = category
if sort:
params["sort"] = sort
if limit is not None:
# Server caps at 25; clamp here so a bad widget value is a smaller page
# rather than a 400. `is not None` rather than a truthiness check so
# limit=0 clamps to 1 instead of silently falling back to the default.
params["limit"] = str(max(1, min(int(limit), 25)))
if offset:
# Server caps at 200 (the width of its in-memory ranking pool); clamp so
# paging past the end is an empty page, not a 400. Falsy skips it: 0 is
# the default and belongs in the URL no more than None does.
params["offset"] = str(max(0, min(int(offset), 200)))
base_url = f"{base.rstrip('/')}/styles"
return f"{base_url}?{urllib.parse.urlencode(params)}" if params else base_url
# ── transport ────────────────────────────────────────────────────────────────
def response_header(headers: dict[str, str], name: str) -> str | None:
"""
Case-insensitive lookup into a response's headers.
`request_raw` hands back `dict(response.headers)`, whose keys keep whatever
casing arrived on the wire — and an origin or proxy is free to normalize
`X-StyleRef-Canonical-Url` to any case. An exact-case `.get()` would then
silently miss a header that is present, so read them case-insensitively.
"""
target = name.lower()
for key, value in headers.items():
if key.lower() == target:
return value
return None
def _auth_header(anonymous: bool) -> dict[str, str]:
if anonymous:
return {}
# Imported lazily: styleref_auth imports this module, and the login flow
# must be able to run before any credentials exist.
from styleref_auth import bearer_token
token = bearer_token()
return {"Authorization": f"Bearer {token}"} if token else {}
def _raise_for_error(err: urllib.error.HTTPError) -> None:
"""Turn an HTTPError into a StyleRefError carrying the server's own copy."""
raw = ""
with contextlib.suppress(Exception):
# Body is best-effort context; the status line alone is still useful.
raw = err.read().decode("utf-8", "replace")
payload: dict[str, Any] = {}
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
payload = parsed
except ValueError:
pass
message = payload.get("message") or raw.strip() or f"{err.code} {err.reason}"
if err.code == 401 and (os.environ.get("STYLEREF_TOKEN") or "").strip():
# The env token never refreshes itself (unlike stored credentials), so
# on a headless box an expired token is the overwhelmingly likely cause.
message += (
"\n\nYour STYLEREF_TOKEN may have expired — copy a fresh token from "
f"{SITE_URL}/account."
)
if err.code == 429:
retry = err.headers.get("Retry-After") if err.headers else None
message = (
f"Rate limited by StyleRef — wait {retry}s and retry."
if retry
else "Rate limited by StyleRef — wait a moment and retry."
)
raise StyleRefError(
message, status=err.code, code=payload.get("error"), payload=payload
) from err
def request_raw(
url: str,
method: str = "GET",
body: bytes | None = None,
headers: dict[str, str] | None = None,
anonymous: bool = False,
timeout: int = DEFAULT_TIMEOUT_S,
retries: int = 2,
) -> tuple[bytes, dict[str, str]]:
"""
Perform a request, returning (body, response headers).
Retries only idempotent GETs on transport errors and 5xx — a retried POST
could repeat a server-side action (or a charge) the caller only asked
for once.
"""
final_headers = {"User-Agent": USER_AGENT, "X-StyleRef-Client": CLIENT_TAG}
final_headers.update(_auth_header(anonymous))
final_headers.update(headers or {})
attempts = retries + 1 if method == "GET" else 1
last_err: Exception | None = None
for attempt in range(attempts):
req = urllib.request.Request(url, data=body, headers=final_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
return res.read(), dict(res.headers)
except urllib.error.HTTPError as err:
# 304 Not Modified is a success for a conditional GET: the caller's
# cached copy is still current. urllib models it as an error.
if err.code == 304:
return b"", dict(err.headers or {})
# 4xx is the caller's problem and will not change on retry.
if err.code < 500 or attempt == attempts - 1:
_raise_for_error(err)
last_err = err
except urllib.error.URLError as err:
if attempt == attempts - 1:
host = urllib.parse.urlparse(url).netloc
raise StyleRefError(
f"Could not reach {host} ({err.reason}). Check the machine running "
"ComfyUI has internet access."
) from err
last_err = err
time.sleep(0.5 * (2**attempt))
raise StyleRefError(f"Request to {url} failed: {last_err}")
def request_json(
url: str,
method: str = "GET",
json_body: dict[str, Any] | None = None,
form_body: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
anonymous: bool = False,
timeout: int = DEFAULT_TIMEOUT_S,
) -> dict[str, Any]:
body: bytes | None = None
hdrs = dict(headers or {})
if json_body is not None:
body = json.dumps(json_body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
elif form_body is not None:
body = urllib.parse.urlencode(form_body).encode("utf-8")
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
raw, _ = request_raw(
url, method=method, body=body, headers=hdrs, anonymous=anonymous, timeout=timeout
)
if not raw:
return {}
try:
parsed = json.loads(raw)
except ValueError as err:
raise StyleRefError("StyleRef returned a malformed response.") from err
return parsed if isinstance(parsed, dict) else {"data": parsed}
def request_text(url: str, anonymous: bool = False, timeout: int = DEFAULT_TIMEOUT_S) -> str:
raw, _ = request_raw(url, anonymous=anonymous, timeout=timeout)
return raw.decode("utf-8", "replace")
# ── endpoints ────────────────────────────────────────────────────────────────
def search_styles(
query: str = "",
category: str | None = None,
sort: str | None = None,
limit: int = 12,
offset: int = 0,
) -> dict[str, Any]:
"""
GET /styles — anonymous gallery search.
The response carries `hasMore` / `nextOffset` alongside `styles`; the picker
pages by feeding `nextOffset` back as `offset`.
"""
return request_json(
build_search_url(api_base(), query, category, sort, limit, offset), anonymous=True
)
def get_style_text(
ref: str,
fmt: str = "default",
compact: bool = False,
raw: bool = False,
sections: str | None = None,
) -> str:
"""
GET /styles/{ref} — the compiled prose spec for one target.
`raw=True` returns exactly what the generator produced, with no metadata
header and no appended attribution line. That is what a prompt encoder
wants: the header is markdown for humans and would be encoded as literal
tokens. Attribution is surfaced on the node's own output instead.
Not anonymous: a private style only resolves with a bearer token, and the
same call serves both public and private refs.
"""
return request_text(
build_style_url(api_base(), ref, fmt=fmt, sections=sections, compact=compact, raw=raw)
)
def get_style_json(ref: str) -> dict[str, Any]:
"""GET /styles/{ref}?format=json — the structured spec the Facets node reads."""
return get_style_spec(ref)[0]
def get_style_spec(
ref: str, etag: str | None = None
) -> tuple[dict[str, Any] | None, str | None, str | None]:
"""
GET /styles/{ref}?format=json → (structured spec, canonical URL, ETag).
Pass a previously returned `etag` to revalidate a cached copy: when the
style hasn't changed server-side, the server answers 304 with no body and
this returns `(None, canonical, etag)` — the caller keeps its cache.
The canonical URL comes from the server's `X-StyleRef-Canonical-Url`
response header, which is authoritative for every ref shape — a bare slug,
a share URL, an own-style id, a /styles/{id} URL. Building it client-side
as `{SITE_URL}/share/{ref}` is only correct for a bare slug and produces a
broken link for the others, so the attribution output reads it from here.
"""
conditional = {"If-None-Match": etag} if etag else {}
raw, headers = request_raw(build_style_url(api_base(), ref, fmt="json"), headers=conditional)
canonical = response_header(headers, "X-StyleRef-Canonical-Url")
new_etag = response_header(headers, "ETag")
if not raw and etag:
# 304 Not Modified — the cached spec is still current.
return None, canonical, etag
payload: dict[str, Any] = {}
if raw:
try:
parsed = json.loads(raw)
except ValueError as err:
raise StyleRefError("StyleRef returned a malformed response.") from err
payload = parsed if isinstance(parsed, dict) else {"data": parsed}
return payload, canonical, new_etag
def save_style(ref: str) -> dict[str, Any]:
"""
POST /styles/{ref}/save — save (like) a gallery style to the caller's
library. Idempotent server-side; requires the styles:write scope.
"""
url = f"{api_base().rstrip('/')}/styles/{encode_ref(ref)}/save"
return request_json(url, method="POST", json_body={})
def list_my_styles(
limit: int = 25, cursor: str | None = None, query: str = ""
) -> dict[str, Any]:
"""
GET /me/styles — the caller's own library. Requires styles:read.
`query` narrows by style name, so the picker's one search box filters the
user's own styles as well as the gallery. Paging is keyset, not offset: pass
the previous response's `nextCursor` back as `cursor`.
Each item carries `lastGeneratedAt` — null means the style has never been
generated on styleref.io, and loading it will fail with 409
`styleref_not_generated`. The picker flags those rows rather than letting the
user find out at queue time.
"""
params: dict[str, str] = {"limit": str(max(1, min(limit, 50)))}
if cursor:
params["cursor"] = cursor
if query.strip():
params["query"] = query.strip()
url = f"{api_base().rstrip('/')}/me/styles?{urllib.parse.urlencode(params)}"
return request_json(url)
def list_saved_styles(
query: str = "", category: str | None = None, limit: int = 8, offset: int = 0
) -> dict[str, Any]:
"""
GET /me/saved-styles — gallery styles the caller saved. Requires styles:read.
The picker's "Saved" sort. Same card shape as `search_styles`, so the dialog
renders the rows with the same code; offset-paged like the gallery lane.
"""
params: dict[str, str] = {"limit": str(max(1, min(limit, 25)))}
if query.strip():
params["query"] = query.strip()
if category:
params["category"] = category
if offset:
params["offset"] = str(max(0, min(offset, 500)))
url = f"{api_base().rstrip('/')}/me/saved-styles?{urllib.parse.urlencode(params)}"
return request_json(url)
# Note: this client deliberately has no extraction endpoints. Extraction is a
# web-app experience (preview, per-block editing, history, free re-apply), so
# the pack loads styles rather than making them. The REST /extractions tier
# itself remains available for CLI and agent surfaces.