Responses API
POST /v1/responses
Matches OpenAI’s Responses endpoint for multimodal input/output with all providers.
PickLayer-specific parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
service_tier | string | — | Priority/flex tier. 'priority' (fast), 'auto' (standard default), 'flex' (lower-cost). See Service Tier. |
Basic Request
{
"model": "openai/gpt-4.1-mini",
"input": [
{ "type": "input_text", "text": "Summarize this image" },
{ "type": "input_image", "image_url": { "url": "<file-uuid>", "detail": "high" } }
],
"response_format": { "type": "json" },
"max_output_tokens": 256
}Content parts
The input array carries content parts. The supported part types are:
| Type | Description |
|---|---|
input_text | Plain text. { "type": "input_text", "text": "..." } |
input_image | Image attachment by PickLayer UUID. { "type": "input_image", "image_url": { "url": "<picklayer-uuid>", "detail": "auto"|"low"|"high" } } |
input_audio | Audio attachment by PickLayer UUID. { "type": "input_audio", "input_audio": { "url": "<picklayer-uuid>", "format": "wav" } } |
input_file | Document attachment (PDF, DOCX, XLSX, MD, TXT, CSV, JSON, code, etc.). See below. |
Unknown content types return 400 invalid_value with a precise param path. Parts are never silently dropped.
input_file (document attachments)
Canonical-rejoin change.
input_fileis part of the canonical OpenAI Responses API surface; PickLayer was previously off-canonical by silently dropping it during input normalization. This change rejoins canon — clients sending standard OpenAI Responses requests withinput_fileparts now get the documented behavior (200 with the file routed to the model, or a precise 400 with the offendingparam).
The Responses API accepts an input_file content part for document attachments. Three forms:
// Form A — reference a previously-uploaded PickLayer file (recommended).
// Must be a PickLayer UUID. Provider-native file IDs are rejected with 400.
{ "type": "input_file", "file_id": "2d5a5194-105c-4d4b-ab46-d2450f447d4f" }
// Form B — inline base64 (small docs only).
{ "type": "input_file",
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0xLjQKJ..." }
// Form C — external URL passthrough.
{ "type": "input_file", "file_url": "https://example.com/policy.pdf" }Per-provider routing. PickLayer translates from the canonical input_file shape to each provider’s native form:
| File class | OpenAI | Anthropic | Other |
|---|---|---|---|
Native input_file with file_url (signed CDN URL) preferred; falls back to inline file_data. PDFs receive both text and per-page images. | document block (source: { type: "url"|"base64"|"file" }). | Adapter-specific. | |
Plain text (.txt, .md, .csv, .tsv, .json, .yaml, .xml, .html, code) | Server-side UTF-8 read, inlined as input_text with <file:filename> framing. | Inlined as text block. | Inlined as text. |
Office (.docx, .xlsx, .pptx) | Server-side text extraction (mammoth/xlsx/officeparser); inlined as text. (Or native input_file with purpose: user_data where supported.) | Server-side extraction; inlined as text. | Server-side extraction. |
| Unsupported MIME for the chosen model | 400 invalid_value with param pointing at the offending content part. Never silently dropped. | Same. | Same. |
The model’s accepted_extensions and accepted_mime_prefixes (from /v1/models) reflect what the runtime pipeline can actually route — no aspirational entries.
When the referenced file has is_encrypted: true, supply the per-file key in the request’s top-level file_keys map. The same encryption pipeline used for images and audio applies — both inline and signed-CDN-URL paths are supported.
Example — PDF attachment via PickLayer UUID:
curl -sS -X POST "$BASE/v1/responses" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4o",
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "Please summarize the attached file." },
{ "type": "input_file", "file_id": "2d5a5194-105c-4d4b-ab46-d2450f447d4f" }
]
}]
}'Attaching encrypted files
Canonical-additive extension.
file_keysis a PickLayer-only top-level field. Clients sending canonical OpenAI Responses requests with plaintext files are unaffected — the field has no effect unless a referenced file hasis_encrypted: true. End-to-end file encryption is not part of any canonical chat API; this field exists to preserve the trust model (UMK never leaves the client) without forking the canonical request shape.
When a chat request references a PickLayer file with is_encrypted: true, the gateway needs the per-file key to decrypt before forwarding plaintext to the upstream provider. The UMK never leaves the client, so the client supplies a per-file key per-request via the new top-level file_keys field. See Encryption — Attaching encrypted files to chat requests for the full architectural reference.
Field shape
{
"model": "openai/gpt-4o",
"input": [
{ "type": "input_text", "text": "What animal is in this image?" },
{ "type": "input_image", "image_url": { "url": "<picklayer-uuid>" } }
],
"file_keys": {
"<picklayer-uuid>": "<base64url-derived-key>"
}
}file_keysis a top-level object:{ [picklayerFileUuid]: base64urlPerFileKey }.- One entry per file (not per reference). A UUID referenced multiple times in the same request appears once in
file_keys. - Plaintext files do not appear here. A stray entry returns
400 unexpected_file_key. - The map is request-scoped and never persisted (logger redaction enforced).
Path selection
| Method | When | Wire shape to provider |
|---|---|---|
| Inline data URL | Bytes ≤ provider maxInlineBytes. | data:image/jpeg;base64,… (server decrypts in-process). |
| Signed CDN URL | Bytes exceed inline limit, or adapter prefers URL. | CloudFront URL with opaque &efk=… (AES-256-GCM-wrapped per-file key). Origin decrypts on fetch. The plaintext file_key is never present in the URL. |
The selection is automatic based on size and provider capability — the caller does not choose.
cURL example
curl -sS -X POST "$BASE/v1/responses" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4o",
"input": [
{ "type": "input_text", "text": "What animal is in this image?" },
{ "type": "input_image",
"image_url": { "url": "6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01", "detail": "high" } }
],
"file_keys": {
"6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01": "Ei5dZbVaKQlnhtdtKuecl78o95fryNJPUvcQ9mSmk3c"
},
"stream": true
}'Python SDK
from picklayer import PickLayerClient
from picklayer.crypto import derive_umk_from_password, derive_file_key
client = PickLayerClient(org_handler="acme", app_handler="myapp", token=token)
# Unlock UMK once per session.
keyring = client.keyring_status(include_keyring=True).data["keyring_data"]
umk = derive_umk_from_password("user-password", keyring)
# Re-derive each per-file key from the UUID + UMK on demand.
file_id = "6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01"
file_key = derive_file_key(umk, file_id)
resp = client.responses(
model="openai/gpt-4o",
input=[
{"type": "input_text", "text": "What animal is in this image?"},
{"type": "input_image", "image_url": {"url": file_id, "detail": "high"}},
],
file_keys={file_id: file_key},
stream=True,
)The Python SDK can also auto-populate file_keys if you pass umk= directly to client.responses() — it walks the input tree, identifies encrypted UUIDs, re-derives each key from the UMK, and attaches the map. Pass the explicit map when you want to use a different UMK or have keys already cached.
Failure modes
| Condition | HTTP | code | param |
|---|---|---|---|
Encrypted UUID, no file_keys entry | 400 | missing_file_key | file_keys.<uuid> |
| Wrong key (fingerprint mismatch) | 400 | e2ee_invalid_file_key | file_keys.<uuid> |
| Plaintext UUID with stray entry | 400 | unexpected_file_key | file_keys.<uuid> |
| Legacy V1 encryption | 400 | e2ee_legacy_encryption | file_keys.<uuid> |
Tampered efk on signed URL | 400 (origin) | e2ee_invalid_file_key | — |
The gateway fails closed in every case — the provider is not called, no billing event is generated.
Cross-session re-derivation
To resume an old thread that referenced encrypted files: fetch the message history (each message carries the original file_ids), recover the UMK from the local keyring, and call derive_file_key(umk, file_id) for each referenced UUID. There is no server-side key registry to sync — keys are recomputable from (UMK, file_id) alone.
Orthogonal to encrypt_content
The file_keys field is independent from encrypt_content:
file_keyscontrols inbound decryption — encrypted file references on the request.encrypt_content: truecontrols outbound encryption of stored responsecontent/output(sealed envelope V2 — see End-to-End Encrypted Responses).
You can use either, neither, or both.
Usage Shape
The non-streaming response and the final response.done streaming event carry an OpenAI-native usage object. Cached prompt tokens and reasoning tokens are nested:
{
"usage": {
"input_tokens": 1245,
"output_tokens": 412,
"total_tokens": 1657,
"input_tokens_details": { "cached_tokens": 800 },
"output_tokens_details": { "reasoning_tokens": 96 }
}
}input_tokens_details.cached_tokens— present when the request hit the prompt cache. Both the live and retrieve flows use this nested shape; the canonical flat fieldcached_input_tokensis not forwarded to the customer surface.output_tokens_details.reasoning_tokens— present for thinking models (e.g. o1, GPT-5 reasoning, Claude extended thinking).- Both sub-objects are zero-suppressed when their fields would be 0.
See Prompt Caching for the full cross-endpoint cache-field reference.
Streaming Events
The PickLayer /v1/responses SSE event surface matches the OpenAI Responses API streaming spec :
response.output_text.delta- Text chunk (deltafield)response.reasoning.delta- Reasoning content (thinking models)response.output_item.added- Output item envelope opened (for function_call: carriescall_id,name,status: "in_progress")response.function_call_arguments.delta- Tool call argument chunk (deltafield, partial JSON string)response.function_call_arguments.done- Tool call arguments complete (argumentsfield, full JSON string)response.output_item.done- Output item envelope closed (for function_call: carriesstatus: "completed")response.web_search_call.in_progress/.searching/.completed- Web search lifecycleresponse.completed- Final response with usage (shape per Usage Shape)
Reasoning Controls
For models that support reasoning (like o1-preview):
{
"model": "openai/o1-preview",
"input": "Solve this math problem...",
"reasoning": {
"effort": "high",
"summary": "auto"
}
}Response Storage & Retrieval
By default, responses are ephemeral — they stream back and are not persisted. Set store: true to save the response for later retrieval.
Storing a Response
{
"model": "openai/gpt-4o",
"input": "Summarize the quarterly results",
"store": true
}Storage is also triggered implicitly by:
background: true— creates a background job (returns 202 immediately)conversation: { id: "..." }— links to a conversation threadprevious_response_id: "resp_..."— replies to a previous response
Stored responses expire after 30 days.
Retrieving a Stored Response
GET /v1/responses/:idAccepts either a PickLayer UUID or a provider response ID (resp_*). Returns an OpenAI-compatible response object.
{
"id": "resp_abc123",
"object": "response",
"status": "completed",
"created_at": 1710000000,
"model": "openai/gpt-4o",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{ "type": "output_text", "text": "The quarterly results show..." }
]
}
],
"usage": {
"input_tokens": 42,
"output_tokens": 256,
"total_tokens": 298,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 }
}
}(input_tokens_details / output_tokens_details are zero-suppressed and omitted entirely when both sub-fields are 0 — shown here for shape reference.)
Returns 404 if the response was not stored, has expired, or doesn’t belong to the current user.
Background Jobs
For long-running requests, use background: true to return immediately:
{
"model": "openai/gpt-4o",
"input": "Write a detailed analysis...",
"background": true,
"max_output_tokens": 16384
}Response (202 Accepted):
{
"id": "resp_abc123",
"object": "response",
"status": "queued",
"metadata": { "job_id": "job_xyz" }
}Poll for completion:
GET /v1/responses/jobs/:jobIdEnd-to-End Encrypted Responses
Responses can be stored with E2EE. The server encrypts using the user’s public key and cannot decrypt. Only the client holding the matching private key can read the stored content.
Setup
1. Initialize keyring (one-time per user):
POST /keyring/init2. Generate an X25519 keypair (client-side — private key never leaves the client).
3. Upload public key:
POST /keyring/public-key{ "public_key": "<base64url-encoded X25519 public key>" }Creating an Encrypted Response
Set encrypt_content: true. No umk field is needed — the server retrieves the public key from the keyring automatically.
encrypt_content: trueencrypts stored response content, not arbitrary request metadata. Anymetadatayou send, plus other operational fields the system needs for routing/retrieval/usage tracking, remains plaintext and server-visible. Do not put secrets, raw prompt text, document excerpts, API keys, or sensitive user data inmetadata.
{
"model": "openai/gpt-4o",
"input": "Summarize the confidential report",
"store": true,
"encrypt_content": true
}Retrieving and Decrypting
GET /v1/responses/:idEncrypted responses include an encryption object alongside the standard response fields. The content and output fields are ciphertext; operational metadata such as model, usage, timestamps, and any request metadata you supplied remains plaintext.
{
"id": "resp_abc123",
"object": "response",
"status": "completed",
"encrypted": true,
"encryption": {
"version": 2,
"envelope": {
"ephemeral_public_key": "<base64url>",
"wrapped_dek": "<base64url>",
"wrapped_dek_nonce": "<base64url>",
"alg": "x25519-xchacha20poly1305"
},
"encrypted_fields": ["content", "output"],
"encrypted_at": "2026-03-25T12:00:00Z"
},
"usage": {
"input_tokens": 42,
"output_tokens": 256,
"total_tokens": 298
}
}Decrypt on the client using the private key and the envelope fields.
Background Encrypted Jobs
{
"model": "openai/gpt-4o",
"input": "Write analysis...",
"background": true,
"encrypt_content": true
}The background job encrypts before persisting. Poll GET /v1/responses/jobs/:jobId as normal; the completed response will include the encryption envelope.
Encryption Metadata Fields
| Field | Description |
|---|---|
version | 2 for public-key encryption |
envelope.ephemeral_public_key | Server’s ephemeral X25519 public key (base64url) |
envelope.wrapped_dek | Inner DEK encrypted with the X25519 shared secret (base64url) |
envelope.wrapped_dek_nonce | Nonce for wrapped_dek decryption |
envelope.alg | x25519-xchacha20poly1305 |
sealed_dek | Content DEK encrypted with the inner DEK (base64url) |
sealed_dek_nonce | Nonce for sealed_dek decryption |
encrypted_fields | List of encrypted field names (e.g., ["content", "output"]) |
encrypted_at | ISO timestamp |
Decryption Steps
The V2 envelope uses a two-layer DEK scheme. To decrypt:
- Derive shared secret:
shared_secret = X25519(private_key, envelope.ephemeral_public_key) - Unwrap inner DEK:
inner_dek = XChaCha20-Poly1305.decrypt(envelope.wrapped_dek, shared_secret, envelope.wrapped_dek_nonce) - Unwrap content DEK:
content_dek = XChaCha20-Poly1305.decrypt(sealed_dek, inner_dek, sealed_dek_nonce) - Decrypt each field:
plaintext = XChaCha20-Poly1305.decrypt(field.ciphertext, content_dek, field.nonce)
Important: The
wrapped_dekdoes not contain the content DEK directly. It contains an intermediate inner DEK which must first be used to unwrapsealed_dekto obtain the actual content DEK. Attempting to decrypt content fields with the inner DEK will produce a MAC authentication error.
Security Model
- The server never possesses the private key
- Each response uses a unique ephemeral keypair (forward secrecy per response)
- Two-layer key wrapping isolates the content key from key-agreement material
- Content is encrypted with XChaCha20-Poly1305 (AEAD)
- Only
contentandoutputare encrypted; model, usage, timestamps, requestmetadata, and other operational metadata remain plaintext for billing and lifecycle tracking
Python SDK Example
from picklayer import PickLayerClient
from picklayer.crypto import CryptoHelper
# One-time setup
client = PickLayerClient(org_handler="myorg", app_handler="myapp")
client.set_token(token)
# Unlock keyring and generate keypair
crypto = CryptoHelper()
crypto.derive_umk("password", keyring_data)
crypto.generate_keypair()
# Upload keypair to server (public key in plaintext, private key wrapped with UMK)
wrapped_sk = crypto.wrap_and_store_private_key()
client.keyring_upload_keypair(crypto.public_key, wrapped_sk)
# Create encrypted response
resp = client.responses(
{"model": "openai/gpt-4o", "input": "Confidential analysis"},
store=True,
encrypt_content=True,
)
# Retrieve and decrypt (on any device — just need your password)
keyring = client.keyring_get_data()
crypto.derive_umk("password", keyring.data["keyring_data"])
crypto.load_private_key(keyring.data["wrapped_private_key"])
stored = client.get_response(resp.data["id"])
if stored.data.get("encrypted"):
decrypted = crypto.decrypt_response_auto(
stored.data, stored.data["encryption"]
)Cancel & Delete
| Endpoint | Description |
|---|---|
POST /v1/responses/:id/cancel | Cancel an in-progress response. Returns 409 if already completed. |
DELETE /v1/responses/:id | Hard-delete a stored response (returns 204). |
Response Statuses
| Status | Description |
|---|---|
queued | Job created, waiting to start |
in_progress | Currently generating |
completed | Successfully finished |
failed | Generation failed |
cancelled | Cancelled by user |
incomplete | Stopped early (e.g., max tokens reached) |
Python SDK
# Store a response
resp = client.responses(
model="openai/gpt-4o",
input="Summarize the results",
store=True
)
response_id = resp.data["id"]
# Retrieve later
stored = client.get_response(response_id)
# Background job
resp = client.responses(
model="openai/gpt-4o",
input="Write a detailed report...",
background=True
)
result = client.wait_for_job(resp.data["metadata"]["job_id"])
# Cancel
client.cancel_response(response_id)
# Delete
client.delete_response(response_id)client.responses(..., encrypt_content=True) follows the same rule as the raw API: stored response content is encrypted, but request metadata and other operational metadata remain plaintext/server-visible. Treat metadata as non-secret.