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 serve at 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 use auth: env.

In short

  • Clients change only their base URL; the proxy forwards to upstream.base_url and 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-vllm plugin on the upstream.
  • Every other /v1 route passes through byte for byte and unmarked, including /v1/responses.
  • laidline serve listens on 127.0.0.1 only; --host 0.0.0.0 exposes it, and server.mark_token_env adds 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_format on a non-gpt-image model: 400 invalid_request_error / unsupported_response_format (a lenient upstream could ignore the value and return an image the proxy never saw);
  • stream: true on any image route: 400 laidline_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 400 invalid_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> is assets.public_base_url when 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 set public_base_url to the address clients use.
  • Each write sweeps assets.path and 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.laidline to 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's key_id is replaced; a client's scheme (synthid or kgw, as an object or a JSON string) is honoured;
  • for stream: true, sets stream_options.include_usage: true so 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 extra data: line (with choices: []) before [DONE];
  • reads the plugin's ack headers (x-laidline-watermarked: 1 and x-laidline-scheme; a scheme of unknown means "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's Authorization header is forwarded. Clients hold their own upstream keys.
  • auth: env: the client's Authorization is replaced with Bearer $<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_env and put a token of at least 32 characters in it. The mark, job and proxy routes then answer 401 unauthorized unless the request carries Authorization: Bearer <token>. /healthz, /readyz, /metrics, /detect and /v1/assets/* stay open, and /admin has 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: env with the token. The inbound Authorization header is removed before the request is forwarded, so with auth: passthrough the upstream receives no key.
  • Asset URLs stay open. GET /v1/assets/* needs no token: the URLs are unguessable and expire with assets.ttl_hours, so a browser can load a url response 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 /v1 wins over the passthrough.
  • /v1/responses and 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