Skip to content

files.upload() is typed to return FileMetadata, but the actual upload response is FileItem-shaped #413

Description

@Fedir-Yatsenko

Name and Version

aidial-client 0.13.0

What steps will reproduce the bug?

  1. Upload any file via the client:

    Reproduction snippet
    from aidial_client import AsyncDial
    
    dial = AsyncDial(base_url=..., api_key=...)
    metadata = await dial.files.upload("files/<bucket>/folder/file.png", file=...)
    print(type(metadata))       # aidial_client.types.metadata.FileMetadata
    print(metadata.next_token)  # None — always
    print(metadata.items)       # None — always
  2. Inspect the declared return type: FileMetadata in aidial_client/types/metadata.py declares next_token, items and etag alongside content_length/content_type.

What is the expected behavior?

upload() should return a type that models the actual upload response. Per the DIAL API documentation for uploadFile, the 200 response body is exactly the shape of the SDK's FileItem model, not FileMetadata:

Documented 200 response body
{
  "name": "file.png",
  "parentPath": "folder1/folder2",
  "bucket": "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST",
  "url": "files/.../folder1/folder2/file.png",
  "nodeType": "ITEM",
  "resourceType": "FILE",
  "contentLength": 128630,
  "contentType": "image/png"
}

DIAL Core confirms this: the upload endpoint (UploadFileController) responds with the server-side FileMetadata, which extends ResourceItemMetadata — an item shape (plus optional etag, createdAt, updatedAt, author). The nextToken/items fields exist only on the separate ResourceFolderMetadata returned by the metadata/listing endpoint. An upload response can never contain them.

What do you see instead?

The SDK reuses one FileMetadata model for two different server responses — the upload response (item-shaped) and the metadata/listing response (folder-shaped) — by unioning their fields and making everything optional:

Current SDK models (aidial_client/types/metadata.py)
class FileItem(BaseMetadata):
    content_length: int | None = None
    content_type: str | None = None
    updated_at: int | None = None

class FileMetadata(BaseMetadata):
    content_length: int | None = None
    content_type: str | None = None
    next_token: str | None = None   # listing-only, never present on upload
    items: list[FileItem] | None = None  # listing-only, never present on upload
    etag: str | None = None

Consequences for client code:

  • upload() advertises next_token/items, which are dead fields (always None) for an upload response.

  • The actual upload response is under-modeled: DIAL Core sends etag, createdAt, updatedAt and author, but FileMetadata types only etag; updated_at is declared on FileItem yet missing from FileMetadata, and createdAt/author survive only as untyped extras (extra="allow").

  • Code that works with files from both upload() and folder listings (metadata.get(...).itemslist[FileItem]) has to juggle two types for the same domain object, or convert manually:

    FileItem.model_validate(metadata.model_dump(by_alias=True, exclude_none=True))

Additional information

Suggested fix: type upload() (and the single-file branch of the metadata resource) as returning FileItem (ideally extended with the typed etag/created_at/updated_at/author fields the server actually sends), and keep FileMetadata as the folder-listing response only. Since every field involved is optional, this is unlikely to break deserialization of existing payloads, though it is a typing-level API change for callers who annotate the return value as FileMetadata.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions