Generic JSON adapter
Read this if your image vendor is not OpenAI-shaped (fal, getimg, Runware, a JSON API of your own) and you want its images marked on the way back. You will add
adapter.rulestolaidline.yamland point your client at the sidecar. Skip if the vendor speaks the OpenAI routes (OpenAI-compatible proxy) or you mark from code (Quickstart). Time: 15 minutes. Needs:pip install "laidline[image,server]", the vendor's route and, forauth: env, its key.
In short
- Each rule is a route on the sidecar; it forwards to the vendor and marks the images that a JSONPath finds.
- Four kinds:
b64,data_uri,url(fetched, marked and re-served from the local asset store) andbody. - Every other field of the vendor's JSON comes back unchanged; no match is a 502 unless the rule says
on_no_match: pass. - For
urlrules, list the vendor's asset hosts infetch_hosts: empty allows any public host, and private addresses are always refused. - Like the whole sidecar, rules listen on 127.0.0.1 and honour the optional inbound token (see Access).
laidline serve marks images in vendor responses that are not OpenAI-shaped (fal, getimg, Runware, a JSON API of your
own). Each rule under adapter.rules is a route on the sidecar: the request is forwarded to the rule's upstream, the
images are found in the response with an RFC 9535 JSONPath, marked (watermark, C2PA manifest, evidence entry) and put
back where they were. Every other field of the vendor's response is returned unchanged. Clients change only their base
URL and path.
upstream: # the default upstream for rules without their own base_url: https://api.vendor.example auth: passthrough adapter: rules: # [a-z0-9][a-z0-9_-]{0,31}; recorded as route adapter:<name> - name: getimg path: /v1/flux-schnell/text-to-image upstream: { base_url: https://api.getimg.ai, auth: env, api_key_env: GETIMG_API_KEY } jsonpath: $.image # base64 image in the JSON response kind: b64 mime_from: image/jpeg # fallback when the bytes cannot be sniffed - name: vendor-urls path: /v2/images/generations # URLs; each image is fetched, marked and re-served from /v1/assets/ jsonpath: $.data[*].url kind: url fetch_hosts: [vendor-cdn.example.com] - name: fal path: /fal/{app:path} # a Starlette path template # may reuse the path's placeholders; default: the request path upstream_path: /fal-ai/{app} jsonpath: $.images[*].url kind: data_uri # data:<mime>;base64,<data> mime_from: $.images[*].content_type
Changes to assets, upstream and adapter.rules take effect on restart; SIGHUP re-reads only the signing keys
(key rotation).
Rule fields
| Key | Default | Meaning |
|---|---|---|
name |
required | Rule name, [a-z0-9][a-z0-9_-]{0,31}, unique. Evidence records route: adapter:<name>; responses carry x-laidline-rule: <name>. |
path |
required | Route on the sidecar, a Starlette path template (/fal/{app:path}). It must not be, sit under or match a native route (/v1/mark, /v1/assets, /v1/detect, /detect, /admin, /healthz, /readyz, /metrics) or an intercepted OpenAI route. A rule under /v1 wins over the OpenAI passthrough. |
method |
POST |
GET, POST or PUT. (method, path) must be unique across rules. |
upstream |
the top-level upstream |
The rule's own upstream (base_url, auth, api_key_env, timeout_s, name; upstream). |
upstream_path |
the request path | Upstream path, with {placeholders} from path filled in (percent-encoded, / kept). A placeholder path does not define is a configuration error. The query string is forwarded. |
jsonpath |
required except for body |
RFC 9535 JSONPath starting with $ that selects the string fields holding the images. It must not select the document root. |
kind |
required | b64, url, data_uri or body (below). |
mime_from |
null |
A JSONPath into the response (paired with the matches by position, or one value for all), or a fixed image/png, image/jpeg or image/webp. Used only when the bytes cannot be sniffed; body takes a fixed type only. |
on_no_match |
error |
error: a response with no match is a 502 adapter_no_match (fail closed). pass: it is relayed unmarked. |
max_images |
16 |
1 to 64. More matches than this is a 502 upstream_bad_response. |
max_response_bytes |
67108864 |
The upstream response is read in full up to this size; larger is a 502 upstream_response_too_large. |
fetch_hosts |
[] |
url kind: the hosts images may be fetched from, matched exactly (case-insensitive). Empty allows any public host and start-up logs a warning; set it on every url rule (below). Private addresses are refused whatever the list says. |
adapter.comfyui: true (marking ComfyUI /view outputs) is refused at load: use the LaidlineSaveImage node
(ComfyUI). A rule with a bad path, JSONPath or placeholder stops laidline serve at start with the rule named
(adapter.rules[i] (name): ...).
Kinds
kind |
The selected field holds | Written back as |
|---|---|---|
b64 |
base64 image bytes | base64 of the marked file |
data_uri |
data:<mime>;base64,<data> |
data:<mime>;base64,<data> of the marked file |
url |
an http(s) URL of the image |
a URL of the marked file in the local asset store (GET /v1/assets/<asset_id>.<ext>, assets) |
body |
(no JSONPath) the response body is the image | the marked file as the body, with its own content-type |
The image type comes from the bytes when they can be sniffed, otherwise from the data URI's type, then mime_from,
then (for body) the response's content-type. PNG, JPEG and WebP are marked; anything else fails the request with
503 laidline_error / unsupported_mime.
What happens to a response
- The request body is buffered and forwarded with the client's headers minus hop-by-hop headers,
hostandcontent-length.auth: passthroughforwards the client'sAuthorization;auth: envreplaces it withBearer $<api_key_env>, read on every request (as in the OpenAI proxy). A.or..segment in a path parameter (or in the request path, withoutupstream_path) is refused with 400invalid_pathbefore anything is sent. - Any non-2xx response, and 202 and 204 (a queued job, no content), is relayed unchanged, body and headers. Every other 2xx is read in full and processed; a queued job's result must come back through a route that has a rule of its own.
- For
b64,urlanddata_urithe body must be JSON (otherwise 502upstream_bad_response). The JSONPath's matches are collected; a node selected twice is marked once. Zero matches is a 502adapter_no_matchunlesson_no_match: pass; more thanmax_images, or a match that is not a string, is a 502upstream_bad_response. - Every match is base64-decoded (or, for
url, fetched) before any is marked, so bad base64 or a failed fetch leaves no evidence entries behind. Then each image is marked in match order and replaced in place. - The response keeps the upstream's status and headers (minus the body headers, which describe the new body) and
adds
x-laidline-asset-idandx-laidline-entry-hash(comma-joined, in match order) andx-laidline-rule. Vendor JSON is relayed as parsed,NaNandInfinityincluded.
Evidence: one marked entry per image with route: adapter:<name>, upstream set to the rule's upstream name
(default: its host), request_id and tenant from the client's x-laidline-request-id and x-laidline-tenant
headers, and extra: {model, index} (model when the request's JSON body has a string model; index is the
match's position). Under on_mark_failure: fail_open a marking failure returns the image re-encoded but unmarked,
with an unmarked entry, as elsewhere.
url images
A url match is fetched by a dedicated client that sends no credentials (neither the client's nor the upstream's),
keeps no cookies and follows no redirects: any status but 200, a redirect included, is a 503 laidline_error /
upstream_asset_fetch_failed, as is a non-http(s) URL, a host outside fetch_hosts or a body over 64 MiB. The
error detail never carries the URL.
Private addresses are refused. Before connecting, the fetcher resolves the host name and checks every address it
returns. If any is loopback (127.0.0.1), link-local (169.254.169.254, the cloud metadata endpoint), private,
multicast, reserved or otherwise not a public address, the fetch fails with upstream_asset_fetch_failed and no
request is made. The connection then goes to one of the addresses that were checked, so a host name that changes its
answer between the check and the connection cannot get through. This holds whatever fetch_hosts says, and also for an
address written in IPv6 form that carries a private IPv4 address. Behind an HTTP proxy the proxy resolves the name, so
only direct connections are checked.
Set fetch_hosts. With an empty list the sidecar fetches from any host that resolves to public addresses, and
start-up logs the warning adapter fetch allows any public host naming the rules concerned. A compromised or
misbehaving upstream could then make the sidecar request any public URL. List the vendor's asset hosts exactly.
Presigned S3 URLs. The host depends on how the URL was presigned: a default boto3 client (1.40) presigns on the
global host <bucket>.s3.amazonaws.com, which may answer with a redirect that the fetcher does not follow, while a
client with a regional endpoint presigns on <bucket>.s3.<region>.amazonaws.com. fetch_hosts must list the host
the URLs actually use; prefer regional presigning.
The marked file is written to the local asset store and served from /v1/assets/, under
assets.public_base_url (set it behind a load balancer). The store is a local directory: with several sidecar
replicas behind one address, give them a shared volume, or a URL minted by one replica 404s on another.
Metrics
Each mark is counted in laidline_marks_total and timed in laidline_mark_seconds under the modality of
what was marked (image, or audio and video when a rule's upstream returns them), as ok, unmarked (neither
a manifest nor a watermark: the fail_open fallback, counted as on /v1/mark/*) or error. The OpenAI image
routes count the same way.
Errors
Error bodies are OpenAI-shaped ({"error": {"message", "type", "code"}}); the default type is
laidline_upstream_error.
| Status | code |
When |
|---|---|---|
| 400 | invalid_path (type invalid_request_error) |
. or .. path segments |
| 400 | invalid_url (type invalid_request_error) |
the upstream URL could not be built |
| 401 | unauthorized (type laidline_error) |
server.mark_token_env is set and the request has no valid Authorization: Bearer <token> |
| 413 | upload_too_large (type laidline_error) |
the request body is over server.max_upload_bytes; the upstream never sees it |
| 500 | upstream_auth (type laidline_config_error) |
auth: env and the key variable is unset or empty |
| 502 | upstream_unreachable, upstream_read |
the upstream is down or failed mid-body |
| 502 | upstream_response_too_large |
larger than max_response_bytes |
| 502 | upstream_bad_response |
non-JSON body, more than max_images matches, or a non-string match |
| 502 | adapter_no_match |
no match and on_no_match: error |
| 503 | <reason> (type laidline_error) |
undecodable_image, upstream_asset_fetch_failed (including a host that resolves to a non-public address), unsupported_mime, or a marking failure under fail_closed (tsa_unavailable, sign_failed, ...) |
| 504 | upstream_timeout |
no answer within the upstream's timeout_s |
Access
The sidecar listens on 127.0.0.1 unless you start it with --host 0.0.0.0. Rules are routes of the same sidecar, so
they share its protection: set server.mark_token_env and every rule route requires Authorization: Bearer <token>
(401 unauthorized otherwise). The token is removed before the request is forwarded, so a rule with auth: passthrough sends the vendor no key; use auth: env. Without a token, keep the sidecar on a private network or behind
your gateway, especially when a rule uses auth: env: anyone who can reach it spends the vendor key. See
Authentication.
What was proven
On 25 Sep 2026 two rules ran against real services: a b64 rule against api.openai.com, and a url rule that fetched a
real presigned S3 URL. Each response was marked, re-served and detected.
Reference
- OpenAI-compatible proxy: the intercepted OpenAI routes, authentication and the asset store.
- HTTP API reference: the native routes and error bodies.
- Configuration: the
adapter,upstreamandassetskeys. - Evidence: the
adapter:<name>entries. - ComfyUI: marking ComfyUI outputs, which the adapter refuses.
- Quickstart: install and first
laidline serve.