OpenAI-compatible proxy
Read this if you run an OpenAI-shaped server (vLLM, api.openai.com, a gateway) and want the images and speech it returns to come back marked without changing client code. You will point
laidline serveat the upstream and change your clients' base URL. Skip if your vendor's responses are not OpenAI-shaped (Generic JSON adapter) or you mark from code (Quickstart). Time: 15 minutes. Needs:pip install "laidline[image,audio,server]", an upstream, and its API key if you useauth: env.
In short
- Clients change only their base URL; the proxy forwards to
upstream.base_urland marks what comes back. - Images (generations, edits, variations) and speech are marked before the client sees them; chat and completions are
logged, and watermarked by the
laidline-vllmplugin on the upstream. - Every other
/v1route passes through byte for byte and unmarked, including/v1/responses. laidline servelistens on 127.0.0.1 only;--host 0.0.0.0exposes it, andserver.mark_token_envadds a bearer token for the routes (see Authentication).- Image streaming is refused (400), and an upstream error is relayed unchanged.
laidline serve sits in front of an OpenAI-shaped server (upstream.base_url: vLLM, api.openai.com,
anything that speaks the same routes). Clients change only their base URL. Images and speech come back marked; chat
and completions are watermarked by the laidline-vllm plugin on the upstream and logged by the proxy; every other
/v1 route passes through byte for byte.
Install: pip install "laidline[image,audio,server]" (server for laidline serve, audio for the speech route; see
the Quickstart).
upstream: # a trailing /v1 is stripped; request paths already start with /v1 base_url: https://api.openai.com auth: env # passthrough | env api_key_env: OPENAI_API_KEY name: openai # recorded as `upstream` in evidence; default: the host name timeout_s: 120 assets: path: /var/lib/laidline/assets # a directory used for nothing else (see "URL responses and the asset store") ttl_hours: 24 public_base_url: null # set behind a load balancer
Start the sidecar. It binds 127.0.0.1 unless you pass --host:
laidline serve --port 8080
Expected output (trimmed; the lines name the bind):
... listening on 127.0.0.1:8080 (loopback only) INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
Send a request from the same machine. Only the base URL differs from a request to OpenAI:
curl localhost:8080/v1/images/generations -H "Authorization: Bearer $OPENAI_API_KEY" \ -H 'content-type: application/json' -d '{"model": "gpt-image-1-mini", "prompt": "a red bicycle", "quality": "low"}'
Expected output (trimmed): the upstream's JSON, with the laidline object added to each image (below).
{"created": ..., "data": [{"b64_json": "...", "laidline": {"asset_id": "ast_...", "manifest_id": "urn:c2pa:...", ...}}], ...}
What just happened: the proxy forwarded the request, marked the returned image (watermark, Content Credentials and
an evidence entry) and answered in the format the client asked for. To serve other machines, start with --host 0.0.0.0 and read Authentication first.
gpt-image models always answer b64_json. A url response (served from the asset store) needs a model that honours
response_format, which no current gpt-image model does; it applies to vLLM-style servers and other upstreams that
accept the parameter.
What is intercepted
The proxy intercepts these routes:
| Route | Kind | What the proxy does |
|---|---|---|
POST /v1/images/generations |
image | Rewrites the request (below), marks every returned image, answers in the format the client asked for. |
POST /v1/images/edits |
image | Same, for a multipart request. The input image with a C2PA manifest becomes the output's ingredient. |
POST /v1/images/variations |
image | As edits. |
POST /v1/chat/completions |
text | Adds vllm_xargs.laidline (JSON-encoded) and, for streams, stream_options.include_usage; records one text evidence entry. |
POST /v1/completions |
text | As chat. |
POST /v1/audio/speech |
audio | Asks the upstream for wav (buffered) or pcm (streaming), marks with AudioSeal, answers in the client's response_format (below). |
Everything else under /v1 (/v1/models, /v1/embeddings, /v1/responses, files, batches) is forwarded unchanged and
is not marked. Images produced through another route, such as /v1/responses with an image tool, leave unmarked.
Paths under Laidline's own namespaces (/v1/assets, /v1/mark, /v1/detect, /v1/jobs) are never forwarded: an
unknown path or method there is a local 404. Those native routes (mark, detect, jobs, assets, health, metrics), their
request shapes and their error bodies are in the HTTP API reference; this page covers only the proxy.
Two settings guard the sidecar's routes as a whole, the proxy's included. server.mark_token_env names a variable
holding a bearer token that the mark, job and proxy routes then require; see
Authentication. server.max_upload_bytes (64 MiB) caps every request body (multipart routes with a
framing allowance), the proxy and adapter routes included; a body over the cap gets 413 upload_too_large in the
OpenAI error shape. Both are described in
Configuration.
Forwarding rules: the request body is buffered, then sent with the client's headers minus hop-by-hop headers, host
and content-length. The upstream is asked for accept-encoding: identity unless the client sent its own, so a raw
relay never hands a compressed body to a client that did not negotiate it. Response headers are relayed minus
hop-by-hop headers and date/server (the ASGI server sets its own); repeated headers such as set-cookie are kept.
Streamed responses always close the upstream connection, however the client leaves.
Images
gpt-image versus dall-e
| Model | Sent upstream | Client gets |
|---|---|---|
gpt-image* (case-insensitive) |
the request without response_format (these models reject it and always return base64, in output_format, png by default) |
b64_json, in the model's output_format |
| anything else (dall-e, vLLM-style servers) | response_format: b64_json |
the client's response_format: url (the default) or b64_json (base64 is accepted as b64_json) |
Refused before anything is sent upstream:
- an unknown
response_formaton a non-gpt-image model: 400invalid_request_error/unsupported_response_format(a lenient upstream could ignore the value and return an image the proxy never saw); stream: trueon any image route: 400laidline_error/stream_not_supported(partial images cannot be marked before they reach the client);- a non-multipart body on edits or variations: 415
invalid_request_error/multipart_required; a malformed multipart body is 400invalid_multipart.
A body that is not a JSON object on /v1/images/generations is forwarded unchanged.
Each image is watermarked (PixelSeal by default; TrustMark selectable), re-encoded, signed (C2PA) and logged, one item at a time. An item the upstream returns as a URL is fetched without any credentials (it may be third-party storage), at most 64 MiB.
Edits and variations
The parts are re-sent under their own field names (image or image[], repeatable, and mask) with the text
fields rewritten by the rules above. The parent is the first image part that carries a readable C2PA manifest
(for example an image this proxy marked earlier), else the first image part. A parent with a manifest becomes every
output's parentOf ingredient titled input, in its own format: a JPEG input under a PNG or WebP output_format
keeps its manifest. The parent's manifest is read once per request. Other inputs are sent upstream but not recorded
as ingredients. When the upstream's output already carries its own manifest and the input has one too, only the
input is kept as the ingredient (the signer records one ingredient).
URL responses and the asset store
With response_format: url the marked bytes are written to assets.path as <asset_id>.<ext> and the item's url
is <base>/v1/assets/<asset_id>.<ext>, served by the same process with Cache-Control: private, max-age=<ttl>.
Files expire after assets.ttl_hours (default 24); an expired or unknown name is a 404.
<base>isassets.public_base_urlwhen set, otherwise the base URL of the incoming request. Behind a load balancer or ingress the request's base URL is the internal address, so setpublic_base_urlto the address clients use.- Each write sweeps
assets.pathand deletes every file older than the TTL, whatever it is. Give the store a directory of its own. - The store is local to one instance. With several replicas, route
/v1/assets/*back to the instance that wrote the file (or give them a shared volume); the asset store is local only, with no shared object store.
Changes to assets, upstream and adapter.rules take effect on restart; SIGHUP re-reads only the signing keys
(key rotation).
The laidline fields and headers
Every marked item gains:
"laidline": {"asset_id": "ast_...", "manifest_id": "urn:c2pa:...", "entry_hash": "...", "warnings": []}
The response carries x-laidline-asset-id and x-laidline-entry-hash, comma-joined in item order when n > 1. All
other upstream fields (created, usage, revised_prompt) and headers are kept. warnings holds
watermark_skipped:<reason> when an image was signed without a watermark (too_small, psnr_below_floor) and,
under fail_open, unmarked:<reason>.
Request headers the proxy records in evidence: X-Laidline-Request-Id (else the upstream's x-request-id) and
X-Laidline-Tenant. The evidence entry has route: proxy:/v1/images/<generations|edits|variations>, upstream
(upstream.name or the host), extra.model and extra.index (the item's position); edits add
c2pa.ingredient_manifest. See Evidence.
Chat and completions
The proxy does not watermark text itself: the laidline-vllm logits processor does, inside the upstream's sampling
loop (vLLM). With watermark.text.enabled: true the proxy:
- sets
vllm_xargs.laidlineto the JSON string{"key_id":"<watermark.text.key_id>","scheme":"<scheme>"}(vLLM answers 400 to a nested object). The proxy owns key selection: a client'skey_idis replaced; a client'sscheme(synthidorkgw, as an object or a JSON string) is honoured; - for
stream: true, setsstream_options.include_usage: trueso the token count arrives in a final usage chunk. That chunk is relayed like every other byte, so a client that did not ask for usage sees one extradata:line (withchoices: []) before[DONE]; - reads the plugin's ack headers (
x-laidline-watermarked: 1andx-laidline-scheme; a scheme ofunknownmeans "installed but not configured" and counts as no ack); - writes one text evidence entry per completion with token counts and the completion's SHA-256 and byte length, never the prompt or the text.
Non-stream responses gain laidline: {asset_id, entry_hash, status, reason} and the x-laidline-asset-id and
x-laidline-entry-hash headers. Streams are relayed byte for byte; x-laidline-asset-id is sent up front and the
entry is written when the stream ends for any reason (there is no entry-hash header). status is ok or unmarked
with a reason: below_min_tokens, greedy_decoding (SynthID below temperature 1e-5) or plugin_missing. With
text disabled, a non-JSON body, or an upstream error (non-200), the request and response pass
through untouched and no entry is written.
watermark.text.require_plugin: true turns a missing ack into a 503 plugin_missing before any completion byte
reaches the client, with a failed evidence entry.
Speech (/v1/audio/speech)
Every utterance the upstream returns is marked with AudioSeal before the client hears it (audio.md). The mode comes
from watermark.audio.speech_mode (default buffered) or, per request, the x-laidline-speech-mode: buffered | streaming header.
| Buffered (default) | Streaming | |
|---|---|---|
| Asked of the upstream | response_format: wav |
response_format: pcm (at watermark.audio.speech_pcm_rate, 24 kHz; at least 16 kHz) |
| Mark | batch mark of the whole utterance, then encoded to the client's format | streaming mark, 20 ms blocks (stream_chunk_ms), encoded incrementally |
| Client formats | mp3 (default), opus, aac, flac, wav, pcm |
pcm, wav, mp3, opus |
| C2PA manifest | embedded in mp3, flac and wav; none in opus, aac, pcm |
none in the stream; a signed mp3 twin when store_signed_twin (below) |
| Evidence entry | written before the response | written when the stream ends, for any reason |
Buffered responses carry x-laidline-asset-id, x-laidline-entry-hash, x-laidline-manifest-id (empty when the
container carries none) and x-laidline-warnings (comma-joined, for example
manifest_skipped:container_unsupported, or watermark_payload_unverified:aac for aac, whose issuer bits do not
read back). pcm answers audio/pcm;rate=<N>.
Streamed responses send x-laidline-asset-id, x-laidline-mode: streaming and, with
watermark.audio.store_signed_twin: true (the default), x-laidline-signed-twin-url with the status line, before any
audio. The twin is the whole marked stream as a signed mp3 under the same asset id, stored in the asset store once the
stream has run to its end; its URL answers 404 until then, and stays 404 when no twin was made (an interrupted stream,
one longer than max_sync_duration_s, a signing failure; the entry's warnings say which). The entry has
watermark.streaming: true, the hash and size of the bytes actually sent, the warning manifest_skipped:streaming
and twin: {manifest_id, sha256, mime} (or null). A stream that did not run to its end (client disconnect, upstream
read error, a marking or encoding failure) gets extra.incomplete: "1": its hash and size cover what was emitted, not
a whole utterance. A marking failure mid-stream ends the response early, since the status line has already gone.
Both modes hold watermark.audio.max_sync_duration_s (120 s): a buffered utterance longer than that is refused with
413 audio_too_long and a failed entry; a stream stops marking at it, finishes its container, stops reading the
upstream and gets extra.incomplete: "1". The entry's extra.model and extra.voice are cut to 128 characters, and
an upstream's own x-laidline-* response headers are dropped, so only Laidline's reach the client. The
first 20 to 40 ms of a stream are unmarked by design, and the streaming mark does not survive a later G.711 or 8 kHz
leg (Benchmarks).
Refused with 400 before anything is sent upstream: stream_format: sse (stream_format_unsupported: SSE-wrapped
audio chunks are not marked), an unknown response_format (unsupported_response_format), an unknown mode header
(invalid_speech_mode) and aac or flac in streaming mode (format_not_streamable). A body that is not a JSON
object is forwarded unchanged; upstream errors are relayed unchanged; a 2xx other than 200 is a 502
upstream_bad_response, never relayed unmarked.
Entries have route: proxy:/v1/audio/speech, upstream, request_id and tenant as for images, and extra: {model, voice, response_format, mode}. The input text is never logged, and the entry has no sha256_in (the upstream wav is
decoded once and marked from its samples).
Errors
All errors are OpenAI-shaped ({"error": {"message", "type", "code"}}), so SDK clients surface them like upstream
errors. Upstream errors (any non-2xx) are relayed unchanged, body and headers.
| Status | type / code |
When |
|---|---|---|
| 400 | invalid_request_error / unsupported_response_format, invalid_multipart, invalid_url |
see above |
| 400 | laidline_error / stream_not_supported |
stream: true on an image route |
| 400 | invalid_request_error / stream_format_unsupported, unsupported_response_format, invalid_speech_mode, format_not_streamable |
speech requests the proxy cannot mark (see "Speech") |
| 401 | laidline_error / unauthorized |
server.mark_token_env is set and the request has no valid Authorization: Bearer <token> |
| 413 | laidline_error / upload_too_large |
the request body is over server.max_upload_bytes: refused before it is read when its Content-Length says so, cut at the cap when it is sent without one; the upstream never sees it |
| 415 | invalid_request_error / multipart_required |
JSON or urlencoded body on edits or variations |
| 500 | laidline_config_error / upstream_auth |
auth: env and the key variable is unset or empty (the message names the variable, never a value) |
| 502 | laidline_upstream_error / upstream_unreachable, upstream_read, upstream_bad_response |
upstream down, failed mid-body, a 2xx other than 200 on an image or speech route, or an image response without a data list |
| 503 | laidline_error / <reason> |
marking failed under fail_closed (tsa_unavailable, sign_failed, ...), or the upstream image could not be decoded or fetched (undecodable_image, upstream_asset_fetch_failed, in either failure mode). Items marked before the failure are logged; no image is returned. For speech: a buffered utterance that could not be marked (undecodable_audio, ...), or a stream whose session could not start. |
| 503 | laidline_upstream_error / plugin_missing |
require_plugin and the upstream did not acknowledge the plugin |
| 504 | laidline_upstream_error / upstream_timeout |
no answer within upstream.timeout_s |
Authentication
auth: passthrough(default): the client'sAuthorizationheader is forwarded. Clients hold their own upstream keys.auth: env: the client'sAuthorizationis replaced withBearer $<api_key_env>. The variable is read on every request, so a rotated key needs no restart.
The upstream key lives only in the environment of the sidecar process. The config file holds the variable's name, never a key, and Laidline never logs or echoes the value.
By default the sidecar asks clients for nothing, so it listens on 127.0.0.1 only: laidline serve is reachable from
the machine it runs on until you start it with --host 0.0.0.0 (the container images do). Before you do, protect the
routes. With auth: env, anyone who can reach the sidecar spends your upstream key.
- Set an inbound token. Name a variable in
server.mark_token_envand put a token of at least 32 characters in it. The mark, job and proxy routes then answer 401unauthorizedunless the request carriesAuthorization: Bearer <token>./healthz,/readyz,/metrics,/detectand/v1/assets/*stay open, and/adminhas its own token. The variable is read on every request, so rotating the token needs no restart; while it is unset or shorter than 32 characters the routes are open, and start-up logs a warning. - Use
auth: envwith the token. The inboundAuthorizationheader is removed before the request is forwarded, so withauth: passthroughthe upstream receives no key. - Asset URLs stay open.
GET /v1/assets/*needs no token: the URLs are unguessable and expire withassets.ttl_hours, so a browser can load aurlresponse directly. - Or keep it private. Put the sidecar on a private network or behind your gateway, and cap request bodies there.
Not intercepted
- Non-OpenAI request and response shapes (fal, Replicate, getimg, Runware JSON APIs): add a rule for the route to
adapter.rules(the generic JSON adapter,generic-adapter.md); a rule under/v1wins over the passthrough. /v1/responsesand any other route not listed above: forwarded unmarked.
What was proven
Run against api.openai.com. On 24 Sep 2026: gpt-image-1-mini generations with n=2 and an edit, each image marked, the
edit's input kept as an ingredient. On 27 Sep 2026: gpt-4o-mini-tts speech, buffered and streamed, with its signed twin.
Reference
- Quickstart: install, first mark and the first
laidline serve. - HTTP API reference: the native routes, headers and error bodies.
- Configuration:
upstream,assets,adapterandserver. - Generic JSON adapter: rules for responses that are not OpenAI-shaped.
- vLLM: the text watermark the proxy asks for.
- Audio: the speech watermark and streaming sessions.
- Evidence: the entries the proxy writes.
- Production: running the sidecar for real traffic.