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.rules to laidline.yaml and 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, for auth: 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) and body.
  • 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 url rules, list the vendor's asset hosts in fetch_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

  1. The request body is buffered and forwarded with the client's headers minus hop-by-hop headers, host and content-length. auth: passthrough forwards the client's Authorization; auth: env replaces it with Bearer $<api_key_env>, read on every request (as in the OpenAI proxy). A . or .. segment in a path parameter (or in the request path, without upstream_path) is refused with 400 invalid_path before anything is sent.
  2. 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.
  3. For b64, url and data_uri the body must be JSON (otherwise 502 upstream_bad_response). The JSONPath's matches are collected; a node selected twice is marked once. Zero matches is a 502 adapter_no_match unless on_no_match: pass; more than max_images, or a match that is not a string, is a 502 upstream_bad_response.
  4. 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.
  5. The response keeps the upstream's status and headers (minus the body headers, which describe the new body) and adds x-laidline-asset-id and x-laidline-entry-hash (comma-joined, in match order) and x-laidline-rule. Vendor JSON is relayed as parsed, NaN and Infinity included.

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