I’m trying to use the Files API to upload largeish pdfs for analysis. However, I get the following error response:
{ "error": { "message": "Additional properties are not allowed ('expires_after' was unexpected)", "type": "invalid_request_error", "param": null, "code": null }
This seems to contradict the documentation, which explicitly mentions an expires_after parameter: platform.openai.com/docs/api-reference/files/create#files_create-expires_after
(not allowed to include links for some reason, so putting them in as code)
One discussion seems to generate a similar issue, and seems to have been resolved by upgrading the python package version. However, I am calling the REST API directly.
It is a bit different than your traditional post REST request using a base64 data, it is a multpart protocol that depends on the language you are using.
In the docs there is a curl example that you can use as a start to convert to your preferred language.
That error usually means you’re hitting the endpoint with an outdated schema on the backend. The docs mention expires_after, but it hasn’t been fully rolled out yet to all REST API endpoints. The Python SDK recently added support, which is why upgrading the client library fixed it for some people.
If you’re calling the REST API directly, two options:
Omit expires_after for now. The upload will work fine without it — you just won’t be able to set an expiry manually.
Double-check API version headers. Some features are only available with the latest API version (the SDK handles this automatically).
So, the docs are slightly ahead of what’s currently enabled in the REST interface. If you remove expires_after from your request, the call should succeed. Once the parameter is fully live, the same request will start working without changes.
AI slop impersonating actual knowledge is not permitted here. “…hasn’t been fully rolled out yet” is bot fabrication on a path to silencing or a ban if it continues.
Uploading files to “files” storage endpoint is by multipart/form-data (emulating a web form)
The “parameters” are other form items without a file name.
For a tutorial of how to do this in general, you can be instructed by this post’s script, sending images and parameters to the images API endpoint in similar fashion, using the httpx Python library:
A complete Python file management utility, about as massive as you’d want without a GUI, which I’ve updated to have an expiration you input by days (instead of seconds). It uses the SDK and bombs out if it detects a openai library module version that is exactly too old.
'''openai file storage utility - upload/download with purpose'''
import sys
from importlib.metadata import version, PackageNotFoundError
from packaging.version import Version, InvalidVersion
import datetime
from pathlib import Path
import inspect # for getting the calling function
from enum import Enum, auto
from dataclasses import dataclass
from typing import Set
try:
openai_ver = version("openai")
except PackageNotFoundError:
sys.exit("Error: openai>=1.100.0 required. Found: none.")
else:
try:
if Version(openai_ver) < Version("1.100.0"):
sys.exit(
f"Error: openai>=1.100.0 required for upload file expiration feature. "
f"Found: {openai_ver}."
)
except InvalidVersion:
sys.exit(f"Error: could not parse installed openai version ({openai_ver}).")
from openai import OpenAI
# Initialize the OpenAI client, which uses environment OPENAI_API_KEY
client = OpenAI()
UTIL_NAME = "OpenAI File Storage Utility"
class Capability(Enum):
UPLOAD = "upload"
DOWNLOAD = "download"
@dataclass(frozen=True)
class Purpose:
name: str
description: str
capabilities: Set[Capability]
def __str__(self):
return self.name
def can(self, capability: Capability) -> bool:
return capability in self.capabilities
@property
def summary(self):
caps = ', '.join(c.name.lower() for c in sorted(
self.capabilities,
key=lambda c: c.name
))
return f"{self.name} - {self.description} ({caps})"
class PurposeRegistry:
_purposes = {
"assistants": Purpose(
name="assistants",
description="Docs for search or code interpreter",
capabilities={Capability.UPLOAD}
),
"assistants_output": Purpose(
name="assistants_output",
description="Files produced by assistant or code",
capabilities={Capability.DOWNLOAD}
),
"user_data": Purpose(
name="user_data",
description="User-provided data",
capabilities={Capability.UPLOAD}
),
"fine-tune": Purpose(
name="fine-tune",
description="JSONL training file",
capabilities={Capability.UPLOAD}
),
"fine-tune-results": Purpose(
name="fine-tune-results",
description="Learning metrics report",
capabilities={Capability.DOWNLOAD}
),
"batch": Purpose(
name="batch",
description="JSONL of API calls to batch",
capabilities={Capability.UPLOAD, Capability.DOWNLOAD}
),
"batch_output": Purpose(
name="batch_output",
description="Fulfilled batch API calls",
capabilities={Capability.UPLOAD, Capability.DOWNLOAD}
),
"vision": Purpose(
name="vision",
description="Images for Assistants message attachment",
capabilities={Capability.UPLOAD}
),
}
@classmethod
def get(cls, name: str) -> Purpose:
return cls._purposes.get(name)
@classmethod
def default(cls) -> Purpose:
return cls._purposes["user_data"]
@classmethod
def list_all(cls):
return list(cls._purposes.values())
def change_purpose(current_purpose: Purpose, expiry_days: int) -> Purpose:
"""
Menu to pick a new file purpose. Returns the selected Purpose.
(expiry_days is passed through but not altered here)
"""
purposes = PurposeRegistry.list_all()
menu_items = [f"[{idx}] {p.summary}" for idx, p in enumerate(purposes, 1)]
menu_items.append(
f"[{len(purposes)+1}] Cancel (keep current: {current_purpose.name})"
)
menu_print(menu_printout="\n".join(menu_items),
purpose=current_purpose,
expiry_days=expiry_days)
choice = input("Enter your choice: ").strip()
if choice.isdigit():
choice = int(choice)
if 1 <= choice <= len(purposes):
selected = purposes[choice - 1]
print(f"Purpose changed to: '{selected.name}'")
return selected
elif choice == len(purposes) + 1:
print("Keeping current purpose.")
return current_purpose
print("Invalid choice. Keeping current purpose.")
return current_purpose
def change_expiry(purpose: Purpose, current_expiry_days: int) -> int:
"""
Prompt the user to set file expiration in days:
0 = disable expiration
1–30 = set that many days
Returns the new expiry_days value (0…30).
"""
# Print header & current expiration, suppressing the "◄ current purpose ►" line
menu_print(
menu_printout=(
f"Current file expiration: "
f"{current_expiry_days if current_expiry_days else 'None'} days\n"
),
purpose=purpose, expiry_days=current_expiry_days,
#print_purpose=False
)
# Prompt immediately on the same line
prompt = "Enter new expiration in days (0 to disable, 1–30 to set), or leave empty to cancel: "
inp = input(prompt).strip()
# Cancel / no change
if inp == "":
kept = (current_expiry_days if current_expiry_days else "None")
print(f"Keeping current expiration of {kept} days.")
return current_expiry_days
# Numeric input?
if inp.isdigit():
days = int(inp)
if days == 0:
print("Expiration disabled (no expiry).")
return 0
if 1 <= days <= 30:
print(f"File expiration set to {days} days.")
return days
# Invalid input
print("Invalid input. Keeping current expiration.")
return current_expiry_days
def menu_print(
menu_name: str = "Main Menu",
menu_printout: str = "",
purpose: Purpose = None,
expiry_days: int | None = None,
print_purpose: bool = True,
) -> None:
if purpose is None:
raise ValueError("A valid 'purpose' must be provided.")
function_to_menu = {
"main": "Main Menu",
"change_purpose": "Select New File 'Purpose'",
"change_expiry": "Set File Expiration",
"change_local_directory": "Select Local Directory",
"create_directory": "Create New Directory",
"upload_file": "Upload File",
"upload_file_old": "Upload File Without Directory",
"list_files": "Listing of Files",
"list_and_delete_file": "Delete from File List",
"list_and_download_file": "Download from File List",
"delete_all_files": "!! Delete All Files !!",
}
caller = inspect.stack()[1].function
menu_title = function_to_menu.get(caller, caller.replace('_', ' ').title())
util_name_len = len(UTIL_NAME)
top_border = f"{'━' * util_name_len}◣"
bottom_border = f"{'▬' * util_name_len}◤"
header = f"{top_border}\n{UTIL_NAME} ▮▮▶ {menu_title}\n{bottom_border}"
print(header)
print(menu_printout, end="")
if expiry_days == 0:
expiry_days = "Unset"
if print_purpose:
print(f"\n◄ current purpose ► {purpose} "
f" ◄ current expiry ► {expiry_days}\n")
else:
print("\n==========\n")
pwd = Path('.').resolve()
def create_directory(purpose: Purpose, expiry_days: int) -> None:
"""
Creates a new directory under the current pwd.
"""
global pwd
menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
new_dir_name = input("Enter the name for the new directory: ").strip()
if not new_dir_name:
print("Directory name cannot be empty. Please try again.")
return
new_dir_path = pwd / new_dir_name
if new_dir_path.exists():
print(f"Directory '{new_dir_name}' already exists. Please try again.")
else:
new_dir_path.mkdir()
print(f"Directory '{new_dir_name}' created successfully.")
def change_local_directory(purpose: Purpose, expiry_days: int) -> None:
"""
Navigate and select a local directory for uploads.
"""
global pwd
MAX_DIRECTORIES = 97
while True:
entries = list(pwd.iterdir())
directories = sorted([d for d in entries if d.is_dir()])[:MAX_DIRECTORIES]
files = sorted([f for f in entries if f.is_file()])
if len(directories) <= 7:
create_dir_option = 8
exit_option = 9
else:
create_dir_option = 98
exit_option = 99
lines = [f"Current directory: {pwd}\n"]
if pwd != pwd.parent:
lines.append("[0] .. (back)")
for idx, d in enumerate(directories, 1):
lines.append(f"[{idx}] [{d.name}]")
if len([d for d in entries if d.is_dir()]) > MAX_DIRECTORIES:
lines.append(f"... (showing first {MAX_DIRECTORIES} directories only)")
lines.append(f"[{create_dir_option}] *Create new directory")
lines.append(f"[{exit_option}] *Exit with no change")
sample = ", ".join(f.name for f in files[:10])
if len(files) > 10:
sample += "..."
lines.append("\n(Sample of files in present directory):")
lines.append(sample)
menu_print(menu_printout="\n".join(lines),
print_purpose=False,
purpose=purpose, expiry_days=expiry_days)
choice = input("Choose a directory number or option: ").strip()
if not choice:
return
if choice.isdigit():
num = int(choice)
if num == exit_option:
return
elif num == create_dir_option:
create_directory(purpose, expiry_days)
elif num == 0 and pwd != pwd.parent:
pwd = pwd.parent
elif 1 <= num <= len(directories):
pwd = directories[num - 1]
else:
print("Invalid choice. Please try again.")
else:
print("Invalid input. Please enter a number.")
def upload_file(purpose: Purpose, expiry_days: int) -> None:
"""
Lists local files by page, lets the user pick one, and uploads it.
If expiry_days > 0, adds an expires_after parameter.
"""
global pwd
PAGE_SIZE = 30
page = 0
while True:
# Gather and paginate files
try:
all_files = sorted(
[f for f in pwd.iterdir() if f.is_file()],
key=lambda p: p.name.lower()
)
except Exception as e:
print(f"Error reading '{pwd}': {e}")
return
total = len(all_files)
if total == 0:
menu_print(menu_name="-- Upload File --", menu_printout="", purpose=purpose)
print(f"No files in directory:\n{pwd}\n")
return
max_page = (total - 1) // PAGE_SIZE
page = max(0, min(page, max_page))
start, end = page * PAGE_SIZE, min((page+1)*PAGE_SIZE, total)
page_files = all_files[start:end]
# Build menu lines...
lines = [
f"Current directory: {pwd}",
f"Showing files {start+1}-{end} of {total}",
""
]
for idx, fpath in enumerate(page_files, 1):
lines.append(f"[{idx}] {fpath.name}")
if page > 0:
lines.append("[97] Previous page")
if end < total:
lines.append("[98] Next page")
lines.append("[99] *Exit without uploading")
menu_print(
menu_name="-- Upload File --",
menu_printout="\n".join(lines),
purpose=purpose, expiry_days=expiry_days
)
choice = input("Choose a file number or an option: ").strip()
if not choice.isdigit():
print("Invalid input. Please enter a number.")
continue
num = int(choice)
if num == 99:
print("Operation cancelled.")
return
if num == 97 and page > 0:
page -= 1
continue
if num == 98 and end < total:
page += 1
continue
if 1 <= num <= len(page_files):
selected = page_files[num-1]
try:
with open(selected, "rb") as fp:
# Build kwargs without 'expires_after' if expiry_days==0
upload_kwargs = {
"file": fp,
"purpose": purpose.name
}
if expiry_days > 0:
upload_kwargs["expires_after"] = {
"anchor": "created_at",
"seconds": expiry_days * 24 * 3600
}
response = client.files.create(**upload_kwargs)
print(f"File uploaded successfully: {response.filename} [{response.id}]")
except FileNotFoundError:
print("File moved or deleted. Try again.")
except PermissionError as e:
print(f"Permission error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
# Remain in loop to allow more uploads
else:
print("Invalid choice. Please try again.")
def get_remote_files(purpose: Purpose):
response = client.files.list(purpose=purpose.name)
return list(response.data)
def print_file_list(files):
if not files:
print("No files found.")
return
for idx, file in enumerate(files, 1):
created = datetime.datetime.fromtimestamp(
file.created_at, datetime.UTC
).strftime('%Y-%m-%d %H:%M:%S UTC')
print(f"[{idx}] {file.filename} [{file.id}], Created: {created}")
if idx % 30 == 0:
input("(press <enter> to continue listing)")
def list_files(purpose: Purpose, expiry_days: int) -> None:
"""
Lists remote files for the selected purpose.
"""
menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
files = get_remote_files(purpose)
print_file_list(files)
input("(<enter> to return)")
def list_and_delete_file(purpose: Purpose, expiry_days: int) -> None:
"""
Lists and allows deletion of a selected remote file.
"""
while True:
menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
files = get_remote_files(purpose)
if not files:
print("No files found.")
return
print_file_list(files)
choice = input(
"Enter a file number to delete, or any other input to return: "
).strip()
if not choice.isdigit():
return
idx = int(choice)
if 1 <= idx <= len(files):
sel = files[idx-1]
client.files.delete(sel.id)
print(f"Deleted: {sel.filename}")
else:
return
def download_file(file_id: str, filename: str, destination: Path) -> None:
response = client.files.content(file_id)
data = response.content
if "." in filename:
root, ext = filename.rsplit(".", 1)
out_name = f"{root}-downloaded.{ext}"
else:
out_name = f"{filename}-downloaded"
out_path = destination / out_name
with open(out_path, "wb") as f:
f.write(data)
print(f"Downloaded: {out_name}")
def list_and_download_file(purpose: Purpose, expiry_days: int) -> None:
"""
Lists and allows downloading of a selected remote file.
"""
while True:
menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
files = get_remote_files(purpose)
if not files:
print("No files found.")
return
print_file_list(files)
choice = input(
"Enter a file number to download, or any other input to return: "
).strip()
if not choice.isdigit():
return
idx = int(choice)
if 1 <= idx <= len(files):
sel = files[idx-1]
download_file(sel.id, sel.filename, pwd)
else:
return
def delete_all_files(purpose: Purpose, expiry_days: int) -> None:
"""
Deletes all files under the current purpose after confirmation.
"""
menu_print(menu_name="-- !! Delete All Files !!", menu_printout="",
purpose=purpose, expiry_days=expiry_days)
confirm = input(
f"This will delete ALL files for purpose '{purpose.name}'.\n"
"Type 'YES' to confirm: "
)
if confirm == "YES":
resp = client.files.list(purpose=purpose.name)
for f in resp.data:
client.files.delete(f.id)
print(f"All files with purpose '{purpose.name}' deleted.")
else:
print("Operation cancelled.")
def env_info():
org = client.organization or "(no organization set)"
proj = client.project or "(no project set)"
api_key = client.api_key or "(no API key set)"
if len(api_key) > 18:
key_elided = f"{api_key[:12]}...{api_key[-6:]}"
else:
key_elided = "(invalid or short API key)"
print(f"Organization: {org}")
print(f"Project: {proj}")
print(f"API Key: {key_elided}")
def main():
env_info()
purpose = PurposeRegistry.default()
expiry_days = 0 # default: no expiration
perm_menu_items = [
("Change purpose (to access different type of API files)", change_purpose, None),
("Set file expiration (in days)", change_expiry, None),
("List remote files only", list_files, None),
("List remote files and download one", list_and_download_file, Capability.DOWNLOAD),
("List remote files and delete one", list_and_delete_file, None),
("Delete all files with current purpose", delete_all_files, None),
("Change local directory", change_local_directory, None),
("Upload file", upload_file, Capability.UPLOAD),
]
while True:
# build menu based on capabilities
menu_items = [
(text, func) for text, func, cap in perm_menu_items
if cap is None or purpose.can(cap)
]
menu_options = {str(i+1): item for i, item in enumerate(menu_items)}
exit_key = "9" if len(menu_options) < 9 else "99"
menu_options[exit_key] = ("Exit", None)
menu_printout = "\n".join(
f"[{k}] {v[0]}" for k, v in sorted(menu_options.items(), key=lambda x: int(x[0]))
)
menu_print(menu_name="-- Main Menu --",
menu_printout=menu_printout,
purpose=purpose, expiry_days=expiry_days)
choice = input("Enter your choice: ").strip()
if choice not in menu_options:
print("Invalid choice. Please try again.")
continue
text, action = menu_options[choice]
if action is None:
print("Exiting.")
break
result = action(purpose, expiry_days)
# update purpose or expiry_days if returned
if isinstance(result, Purpose):
purpose = result
elif isinstance(result, int):
expiry_days = result
if __name__ == "__main__":
main()
Usage:
change the upload purpose (from user_data if needed)
change the expiration (from unset)
change/browse the local directory (upload/download destination)
pick a file to upload
see success
verify your expiration is also seen in the platform site “storage” - Success!
Okay, yep, the issue seems to be that I was passing the contents of expires_after as a JSON object rather than a multipart form object.
The curl example in the API reference seems to have syntax errors when I tried to run it (e.g. missing backslashes on the fourth and fifth lines), but the following modified version of it did work: