diff --git a/backend/app/alembic/versions/050_add_columns_to_collection_job_and_documents.py b/backend/app/alembic/versions/050_add_columns_to_collection_job_and_documents.py
new file mode 100644
index 000000000..3ddcd00b1
--- /dev/null
+++ b/backend/app/alembic/versions/050_add_columns_to_collection_job_and_documents.py
@@ -0,0 +1,62 @@
+"""add columns to collection job and documents table
+
+Revision ID: 050
+Revises: 049
+Create Date: 2026-03-25 10:09:47.318575
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "050"
+down_revision = "049"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column(
+        "collection_jobs",
+        sa.Column(
+            "docs_num",
+            sa.Integer(),
+            nullable=True,
+            comment="Total number of documents to be processed in this job",
+        ),
+    )
+    op.add_column(
+        "collection_jobs",
+        sa.Column(
+            "total_size_mb",
+            sa.Float(),
+            nullable=True,
+            comment="Total size of documents being uploaded to collection",
+        ),
+    )
+    op.add_column(
+        "document",
+        sa.Column(
+            "file_size_kb",
+            sa.Float(),
+            nullable=True,
+            comment="Size of the document in bytes",
+        ),
+    )
+    op.add_column(
+        "collection_jobs",
+        sa.Column(
+            "documents",
+            sa.JSON(),
+            nullable=True,
+            comment="JSON array of document UUIDs included in this job",
+        ),
+    )
+
+
+def downgrade():
+    op.drop_column("document", "file_size_kb")
+    op.drop_column("collection_jobs", "total_size_mb")
+    op.drop_column("collection_jobs", "docs_num")
+    op.drop_column("collection_jobs", "documents")
diff --git a/backend/app/alembic/versions/051_add_openai_file_id_column.py b/backend/app/alembic/versions/051_add_openai_file_id_column.py
new file mode 100644
index 000000000..618b0282f
--- /dev/null
+++ b/backend/app/alembic/versions/051_add_openai_file_id_column.py
@@ -0,0 +1,33 @@
+"""add openai file id column
+
+Revision ID: 051
+Revises: 050
+Create Date: 2026-03-31 14:27:26.391631
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "051"
+down_revision = "050"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column(
+        "document",
+        sa.Column(
+            "openai_file_id",
+            sqlmodel.sql.sqltypes.AutoString(),
+            nullable=True,
+            comment="file id when uploaded to openai",
+        ),
+    )
+
+
+def downgrade():
+    op.drop_column("document", "openai_file_id")
diff --git a/backend/app/api/docs/collections/create.md b/backend/app/api/docs/collections/create.md
index cc85ad36a..bc94e7c45 100644
--- a/backend/app/api/docs/collections/create.md
+++ b/backend/app/api/docs/collections/create.md
@@ -3,9 +3,10 @@ pipeline:
 
 * Create a vector store from the document IDs you received after uploading your
   documents through the Documents module.
-* The `batch_size` parameter controls how many documents are sent to OpenAI in a
-  single transaction when creating the vector store. This helps optimize the upload
-  process for large document sets. If not specified, the default value is **10**.
+* Documents are automatically batched when creating the vector store to optimize
+  the upload process for large document sets. A new batch is created when either
+  the cumulative size reaches 30 MB of documents given to upload to a vector store
+  or the document count reaches 200 files in a batch, whichever limit is hit first.
 * [Deprecated] Attach the Vector Store to an OpenAI
   [Assistant](https://platform.openai.com/docs/api-reference/assistants). Use
   parameters in the request body relevant to an Assistant to flesh out
diff --git a/backend/app/api/routes/collections.py b/backend/app/api/routes/collections.py
index 558e4d867..332e1da8c 100644
--- a/backend/app/api/routes/collections.py
+++ b/backend/app/api/routes/collections.py
@@ -11,6 +11,7 @@
     CollectionCrud,
     CollectionJobCrud,
     DocumentCollectionCrud,
+    DocumentCrud,
 )
 from app.core.cloud import get_cloud_storage
 from app.models import (
@@ -101,6 +102,8 @@ def create_collection(
             action_type=CollectionActionType.CREATE,
             project_id=current_user.project_.id,
             status=CollectionJobStatus.PENDING,
+            docs_num=len(request.documents),
+            documents=[str(doc_id) for doc_id in request.documents],
         )
     )
 
diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py
index 69c5b4895..c66503db6 100644
--- a/backend/app/api/routes/documents.py
+++ b/backend/app/api/routes/documents.py
@@ -12,6 +12,7 @@
     UploadFile,
 )
 from fastapi import Path as FastPath
+from fastapi import HTTPException
 
 from app.api.deps import AuthContextDep, SessionDep
 from app.api.permissions import Permission, require_permission
@@ -27,9 +28,11 @@
     DocTransformationJobPublic,
 )
 from app.core.cloud import get_cloud_storage
-from app.services.collections.helpers import pick_service_for_documennt
+from app.services.collections.helpers import pick_service_for_documennt, MAX_DOC_SIZE_MB
 from app.services.documents.helpers import (
+    calculate_file_size,
     schedule_transformation,
+    schedule_provider_upload,
     pre_transform_validation,
     build_document_schema,
     build_document_schemas,
@@ -119,6 +122,11 @@ async def upload_doc(
     ),
     callback_url: str
     | None = Form(None, description="URL to call to report doc transformation status"),
+    upload_to_providers: list[str]
+    | None = Form(
+        None,
+        description="List of provider names to upload the document to (e.g., ['openai'])",
+    ),
 ):
     if callback_url:
         validate_callback_url(callback_url)
@@ -129,6 +137,20 @@ async def upload_doc(
         transformer=transformer,
     )
 
+    file_size_kb = await calculate_file_size(src)
+    file_size_mb = file_size_kb / 1024
+
+    if file_size_mb > MAX_DOC_SIZE_MB:
+        logger.warning(
+            f"[upload_doc] Document size exceeds limit | "
+            f"{{'filename': '{src.filename}', 'size_mb': {round(file_size_mb, 2)}, 'max_size_mb': {MAX_DOC_SIZE_MB}}}"
+        )
+        raise HTTPException(
+            status_code=413,
+            detail=f"Document size ({round(file_size_mb, 2)} MB) exceeds the maximum allowed size of {MAX_DOC_SIZE_MB} MB. "
+            f"Please upload a smaller file.",
+        )
+
     storage = get_cloud_storage(session=session, project_id=current_user.project_.id)
     document_id = uuid4()
     object_store_url = storage.put(src, Path(str(document_id)))
@@ -137,6 +159,7 @@ async def upload_doc(
     document = Document(
         id=document_id,
         fname=src.filename,
+        file_size_kb=file_size_kb,
         object_store_url=str(object_store_url),
     )
     source_document = crud.update(document)
@@ -151,6 +174,13 @@ async def upload_doc(
         callback_url=callback_url,
     )
 
+    provider_upload_info: dict | None = schedule_provider_upload(
+        project_id=current_user.project_.id,
+        organization_id=current_user.organization_.id,
+        document_id=source_document.id,
+        providers=upload_to_providers or [],
+    )
+
     document_schema = DocumentPublic.model_validate(
         source_document, from_attributes=True
     )
@@ -161,6 +191,7 @@ async def upload_doc(
     response = DocumentUploadResponse(
         **document_schema.model_dump(),
         transformation_job=job_info,
+        provider_upload_job=provider_upload_info,
     )
     return APIResponse.success_response(response)
 
diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py
index a1663179d..d27254ac7 100644
--- a/backend/app/celery/tasks/job_execution.py
+++ b/backend/app/celery/tasks/job_execution.py
@@ -165,3 +165,24 @@ def run_tts_result_processing(
         task_instance=self,
         **kwargs,
     )
+
+
+@celery_app.task(
+    bind=True,
+    queue="low_priority",
+    priority=1,
+    rate_limit="10/m",  # Limit to 10 provider uploads per minute to prevent OOM
+)
+def run_provider_upload_job(
+    self, project_id: int, job_id: str, trace_id: str, **kwargs
+):
+    from app.services.documents.provider_upload import execute_job
+
+    _set_trace(trace_id)
+    return execute_job(
+        project_id=project_id,
+        job_id=job_id,
+        task_id=current_task.request.id,
+        task_instance=self,
+        **kwargs,
+    )
diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py
index e4b2a2e3f..edbe89973 100644
--- a/backend/app/celery/utils.py
+++ b/backend/app/celery/utils.py
@@ -146,6 +146,20 @@ def start_tts_result_processing(
     return task.id
 
 
+def start_provider_upload_job(
+    project_id: int, job_id: str, trace_id: str = "N/A", **kwargs
+) -> str:
+    from app.celery.tasks.job_execution import run_provider_upload_job
+
+    task = run_provider_upload_job.delay(
+        project_id=project_id, job_id=job_id, trace_id=trace_id, **kwargs
+    )
+    logger.info(
+        f"[start_provider_upload_job] Started job {job_id} with Celery task {task.id}"
+    )
+    return task.id
+
+
 def get_task_status(task_id: str) -> Dict[str, Any]:
     result = AsyncResult(task_id)
     return {
diff --git a/backend/app/core/cloud/storage.py b/backend/app/core/cloud/storage.py
index 727380726..a57273a06 100644
--- a/backend/app/core/cloud/storage.py
+++ b/backend/app/core/cloud/storage.py
@@ -125,6 +125,11 @@ def stream(self, url: str) -> StreamingBody:
         """Stream a file from storage"""
         pass
 
+    @abstractmethod
+    def get(self, url: str) -> bytes:
+        """Get file contents as bytes (for files that fit in memory)"""
+        pass
+
     @abstractmethod
     def get_file_size_kb(self, url: str) -> float:
         """Return the file size in KB"""
@@ -193,6 +198,25 @@ def stream(self, url: str) -> StreamingBody:
             )
             raise CloudStorageError(f'AWS Error: "{err}" ({url})') from err
 
+    def get(self, url: str) -> bytes:
+        name = SimpleStorageName.from_url(url)
+        kwargs = asdict(name)
+        try:
+            body = self.aws.client.get_object(**kwargs).get("Body")
+            content = body.read()
+            logger.info(
+                f"[AmazonCloudStorage.get] File retrieved successfully | "
+                f"{{'project_id': '{self.project_id}', 'bucket': '{mask_string(name.Bucket)}', 'key': '{mask_string(name.Key)}', 'size_bytes': {len(content)}}}"
+            )
+            return content
+        except ClientError as err:
+            logger.error(
+                f"[AmazonCloudStorage.get] AWS get error | "
+                f"{{'project_id': '{self.project_id}', 'bucket': '{mask_string(name.Bucket)}', 'key': '{mask_string(name.Key)}', 'error': '{str(err)}'}}",
+                exc_info=True,
+            )
+            raise CloudStorageError(f'AWS Error: "{err}" ({url})') from err
+
     def get_file_size_kb(self, url: str) -> float:
         name = SimpleStorageName.from_url(url)
         kwargs = asdict(name)
diff --git a/backend/app/crud/rag/open_ai.py b/backend/app/crud/rag/open_ai.py
index cdb644abc..8274204f2 100644
--- a/backend/app/crud/rag/open_ai.py
+++ b/backend/app/crud/rag/open_ai.py
@@ -1,6 +1,7 @@
 import json
 import logging
 import functools as ft
+from io import BytesIO
 from typing import Iterable
 
 from openai import OpenAI, OpenAIError
@@ -121,53 +122,35 @@ def update(
         storage: CloudStorage,
         documents: Iterable[Document],
     ):
-        files = []
         for docs in documents:
+            file_ids = []
+            files = []
             for d in docs:
-                f_obj = storage.stream(d.object_store_url)
-
-                # monkey patch botocore.response.StreamingBody to make
-                # OpenAI happy
-                f_obj.name = d.fname
-
-                files.append(f_obj)
-
+                if d.openai_file_id:
+                    # Document already uploaded to OpenAI, use existing file ID
+                    file_id = d.openai_file_id
+                    file_ids.append(file_id)
+                else:
+                    # No OpenAI file ID, fetch from S3 and add to upload list
+                    content = storage.get(d.object_store_url)
+                    f_obj = BytesIO(content)
+                    f_obj.name = d.fname
+                    files.append(f_obj)
             logger.info(
-                f"[OpenAIVectorStoreCrud.update] Uploading files to vector store | {{'vector_store_id': '{vector_store_id}', 'file_count': {len(files)}}}"
+                f"[OpenAIVectorStoreCrud.update] Uploading files to vector store | {{'vector_store_id': '{vector_store_id}', 'new_files': {len(files)}, 'existing_file_ids': {len(file_ids)}}}"
             )
             req = self.client.vector_stores.file_batches.upload_and_poll(
-                vector_store_id=vector_store_id,
-                files=files,
+                vector_store_id=vector_store_id, files=files, file_ids=file_ids
             )
             logger.info(
                 f"[OpenAIVectorStoreCrud.update] File upload completed | {{'vector_store_id': '{vector_store_id}', 'completed_files': {req.file_counts.completed}, 'total_files': {req.file_counts.total}}}"
             )
             if req.file_counts.completed != req.file_counts.total:
-                view = {x.fname: x for x in docs}
-                for i in self.read(vector_store_id):
-                    if i.last_error is None:
-                        fname = self.client.files.retrieve(i.id)
-                        view.pop(fname)
-
-                error = {
-                    "error": "OpenAI document processing error",
-                    "documents": list(view.values()),
-                }
-                try:
-                    raise InterruptedError(json.dumps(error, cls=BaseModelEncoder))
-                except InterruptedError as err:
-                    logger.error(
-                        f"[OpenAIVectorStoreCrud.update] Document processing error | {{'vector_store_id': '{vector_store_id}', 'error': '{error['error']}', 'failed_documents': {len(error['documents'])}}}",
-                        exc_info=True,
-                    )
-                    raise
-
-            while files:
-                f_obj = files.pop()
-                f_obj.close()
-                logger.info(
-                    f"[OpenAIVectorStoreCrud.update] Closed file stream | {{'vector_store_id': '{vector_store_id}', 'filename': '{f_obj.name}'}}"
+                error_msg = f"OpenAI document processing error: {req.file_counts.completed}/{req.file_counts.total} files completed"
+                logger.error(
+                    f"[OpenAIVectorStoreCrud.update] Document processing error | {{'vector_store_id': '{vector_store_id}', 'completed_files': {req.file_counts.completed}, 'total_files': {req.file_counts.total}}}"
                 )
+                raise InterruptedError(error_msg)
 
             yield from docs
 
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
index f8b545404..c0d3e3027 100644
--- a/backend/app/models/collection.py
+++ b/backend/app/models/collection.py
@@ -39,12 +39,10 @@ class Collection(SQLModel, table=True):
         description="Unique identifier for the collection",
         sa_column_kwargs={"comment": "Unique identifier for the collection"},
     )
-    provider: ProviderType = (
-        Field(
-            nullable=False,
-            description="LLM provider used for this collection (e.g., 'openai', 'bedrock', 'google', etc)",
-            sa_column_kwargs={"comment": "LLM provider used for this collection"},
-        ),
+    provider: ProviderType = Field(
+        nullable=False,
+        description="LLM provider used for this collection (e.g., 'openai', 'bedrock', 'google', etc)",
+        sa_column_kwargs={"comment": "LLM provider used for this collection"},
     )
     llm_service_id: str = Field(
         nullable=False,
@@ -102,14 +100,6 @@ class CollectionOptions(SQLModel):
     documents: list[UUID] = Field(
         description="List of document IDs",
     )
-    batch_size: int = Field(
-        default=10,
-        description=(
-            "Number of documents to send to OpenAI in a single "
-            "transaction. See the `file_ids` parameter in the "
-            "vector store [create batch](https://platform.openai.com/docs/api-reference/vector-stores-file-batches/createBatch)."
-        ),
-    )
 
     def model_post_init(self, __context: Any):
         self.documents = list(set(self.documents))
diff --git a/backend/app/models/collection_job.py b/backend/app/models/collection_job.py
index 7c55e8562..abb00e68e 100644
--- a/backend/app/models/collection_job.py
+++ b/backend/app/models/collection_job.py
@@ -1,7 +1,9 @@
 from datetime import datetime
 from enum import Enum
+from typing import Any
 from uuid import UUID, uuid4
 
+from sqlalchemy import JSON
 from sqlmodel import Column, Field, SQLModel, Text
 
 from app.core.util import now
@@ -53,6 +55,28 @@ class CollectionJob(SQLModel, table=True):
         description="Tracing ID for correlating logs and traces.",
         sa_column_kwargs={"comment": "Tracing ID for correlating logs and traces"},
     )
+    docs_num: int | None = Field(
+        default=None,
+        description="Total number of documents to be processed in this job",
+        sa_column_kwargs={
+            "comment": "Total number of documents to be processed in this job"
+        },
+    )
+    total_size_mb: float | None = Field(
+        default=None,
+        description="Total size of documents being uploaded to collection in MB",
+        sa_column_kwargs={
+            "comment": "Total size of documents being uploaded to collection in MB"
+        },
+    )
+    documents: list[str] | None = Field(
+        default=None,
+        sa_column=Column(
+            JSON,
+            nullable=True,
+            comment="JSON array of document UUIDs included in this job",
+        ),
+    )
     error_message: str | None = Field(
         default=None,
         sa_column=Column(
@@ -88,6 +112,11 @@ class CollectionJob(SQLModel, table=True):
         sa_column_kwargs={"comment": "Timestamp when the job was last updated"},
     )
 
+    def model_post_init(self, _context: Any) -> None:
+        """Ensure documents list contains unique values (set behavior)."""
+        if self.documents is not None:
+            self.documents = list(dict.fromkeys(self.documents))
+
     @property
     def job_id(self) -> UUID:
         return self.id
@@ -106,14 +135,22 @@ class CollectionJobCreate(SQLModel):
     collection_id: UUID | None = None
     status: CollectionJobStatus
     action_type: CollectionActionType
+    docs_num: int | None = None
+    documents: list[str] | None = None
     project_id: int
 
+    def model_post_init(self, _context: Any) -> None:
+        """Ensure documents list contains unique values (set behavior)."""
+        if self.documents is not None:
+            self.documents = list(dict.fromkeys(self.documents))
+
 
 class CollectionJobUpdate(SQLModel):
     task_id: str | None = None
     status: CollectionJobStatus | None = None
     error_message: str | None = None
     collection_id: UUID | None = None
+    total_size_mb: float | None = None
     trace_id: str | None = None
 
 
diff --git a/backend/app/models/document.py b/backend/app/models/document.py
index bffa7b39c..0adab7df4 100644
--- a/backend/app/models/document.py
+++ b/backend/app/models/document.py
@@ -37,10 +37,20 @@ class Document(DocumentBase, table=True):
     object_store_url: str = Field(
         sa_column_kwargs={"comment": "Cloud storage URL for the document"},
     )
+    openai_file_id: str | None = Field(
+        default=None,
+        description="file id when uploaded to openai",
+        sa_column_kwargs={"comment": "file id when uploaded to openai"},
+    )
     is_deleted: bool = Field(
         default=False,
         sa_column_kwargs={"comment": "Soft delete flag"},
     )
+    file_size_kb: float | None = Field(
+        default=None,
+        description="The size of the document in kilobytes",
+        sa_column_kwargs={"comment": "Size of the document in kilobytes (KB)"},
+    )
 
     # Foreign keys
     source_document_id: UUID | None = Field(
@@ -80,9 +90,6 @@ class DocumentPublic(DocumentBase):
     updated_at: datetime = Field(
         description="The timestamp when the document was last updated"
     )
-    signed_url: str | None = Field(
-        default=None, description="A signed URL for accessing the document"
-    )
 
 
 class TransformedDocumentPublic(DocumentPublic):
@@ -105,6 +112,10 @@ class TransformationJobInfo(SQLModel):
 class DocumentUploadResponse(DocumentPublic):
     signed_url: str = Field(description="A signed URL for accessing the document")
     transformation_job: TransformationJobInfo | None = None
+    provider_upload_job: dict | None = Field(
+        default=None,
+        description="Information about the provider upload job (if scheduled)",
+    )
 
 
 class DocTransformationJobPublic(SQLModel):
diff --git a/backend/app/services/collections/create_collection.py b/backend/app/services/collections/create_collection.py
index b69154ac4..4d897adae 100644
--- a/backend/app/services/collections/create_collection.py
+++ b/backend/app/services/collections/create_collection.py
@@ -167,6 +167,14 @@ def execute_job(
 
         job_uuid = UUID(job_id)
 
+        with Session(engine) as session:
+            document_crud = DocumentCrud(session, project_id)
+            flat_docs = document_crud.read_each(creation_request.documents)
+
+        file_exts = {doc.fname.split(".")[-1] for doc in flat_docs if "." in doc.fname}
+        total_size_kb = sum(doc.file_size_kb or 0 for doc in flat_docs)
+        total_size_mb = round(total_size_kb / 1024, 2)
+
         with Session(engine) as session:
             collection_job_crud = CollectionJobCrud(session, project_id)
             collection_job = collection_job_crud.read_one(job_uuid)
@@ -175,11 +183,11 @@ def execute_job(
                 CollectionJobUpdate(
                     task_id=task_id,
                     status=CollectionJobStatus.PROCESSING,
+                    total_size_mb=total_size_mb,
                 ),
             )
 
             storage = get_cloud_storage(session=session, project_id=project_id)
-            document_crud = DocumentCrud(session, project_id)
 
             provider = get_llm_provider(
                 session=session,
@@ -191,21 +199,12 @@ def execute_job(
         result = provider.create(
             collection_request=creation_request,
             storage=storage,
-            document_crud=document_crud,
+            documents=flat_docs,
         )
 
         llm_service_id = result.llm_service_id
         llm_service_name = result.llm_service_name
 
-        with Session(engine) as session:
-            document_crud = DocumentCrud(session, project_id)
-            flat_docs = document_crud.read_each(creation_request.documents)
-
-        file_exts = {doc.fname.split(".")[-1] for doc in flat_docs if "." in doc.fname}
-        file_sizes_kb = [
-            storage.get_file_size_kb(doc.object_store_url) for doc in flat_docs
-        ]
-
         with Session(engine) as session:
             collection_crud = CollectionCrud(session, project_id)
 
@@ -239,11 +238,11 @@ def execute_job(
 
         elapsed = time.time() - start_time
         logger.info(
-            "[create_collection.execute_job] Collection created: %s | Time: %.2fs | Files: %d | Sizes: %s KB | Types: %s",
+            "[create_collection.execute_job] Collection created: %s | Time: %.2fs | Files: %d | Total Size: %s MB | Types: %s",
             collection_id,
             elapsed,
             len(flat_docs),
-            file_sizes_kb,
+            collection_job.total_size_mb,
             list(file_exts),
         )
 
diff --git a/backend/app/services/collections/helpers.py b/backend/app/services/collections/helpers.py
index 6275ee40d..4d469ba68 100644
--- a/backend/app/services/collections/helpers.py
+++ b/backend/app/services/collections/helpers.py
@@ -8,13 +8,22 @@
 from fastapi import HTTPException
 from sqlmodel import select
 
-from app.crud import DocumentCrud, CollectionCrud
+from app.crud import CollectionCrud
 from app.api.deps import SessionDep
-from app.models import DocumentCollection, Collection, CollectionPublic
+from app.models import DocumentCollection, Collection, CollectionPublic, Document
 
 
 logger = logging.getLogger(__name__)
 
+# Necessary Constants -
+# Maximum individual document size (must be less than batch size)
+MAX_DOC_SIZE_MB = 25  # 25 MB maximum per document
+
+# Maximum batch size for uploading documents to vector store
+# Derived from MAX_DOC_SIZE + buffer to ensure single docs always fit
+MAX_BATCH_SIZE_KB = (MAX_DOC_SIZE_MB + 5) * 1024  # 30 MB in KB (25 + 5 MB buffer)
+MAX_BATCH_COUNT = 200  # Maximum documents per batch
+
 
 def get_service_name(provider: str) -> str:
     """Get the collection service name for a provider."""
@@ -55,25 +64,52 @@ def extract_error_message(err: Exception) -> str:
     return message.strip()[:1000]
 
 
-def batch_documents(
-    document_crud: DocumentCrud, documents: List[UUID], batch_size: int
-):
-    """Batch document IDs into chunks of size `batch_size`, load each via `DocumentCrud.read_each`,
-    and return a list of document batches."""
+def batch_documents(documents: List[Document]) -> List[List[Document]]:
+    """
+    Batch documents dynamically based on size and count limits.
+
+    Creates a new batch when either:
+    - Total size reaches 30 MB (30,720 KB)
+    - Document count reaches 200
+
+    Args:
+        documents: List of Document objects to batch
+
+    Returns:
+        List of document batches
+    """
+
+    docs_batches = []
+    current_batch = []
+    current_batch_size_kb = 0
+
+    for doc in documents:
+        doc_size_kb = doc.file_size_kb or 0
+
+        would_exceed_size = (current_batch_size_kb + doc_size_kb) > MAX_BATCH_SIZE_KB
+        would_exceed_count = len(current_batch) >= MAX_BATCH_COUNT
+
+        if current_batch and (would_exceed_size or would_exceed_count):
+            docs_batches.append(current_batch)
+            logger.info(
+                f"[batch_documents] Batch completed | {{'batch_num': {len(docs_batches)}, 'doc_count': {len(current_batch)}, 'batch_size_mb': {round(current_batch_size_kb / 1024)}}}"
+            )
+            current_batch = []
+            current_batch_size_kb = 0
+
+        current_batch.append(doc)
+        current_batch_size_kb += doc_size_kb
+
+    if current_batch:
+        docs_batches.append(current_batch)
+        logger.info(
+            f"[batch_documents] Final Batch completed | {{'batch_num': {len(docs_batches)}, 'doc_count': {len(current_batch)}, 'batch_size_mb': {round(current_batch_size_kb / 1024)}}}"
+        )
 
     logger.info(
-        f"[batch_documents] Starting batch iteration for documents | {{'batch_size': {batch_size}, 'total_documents': {len(documents)}}}"
+        f"[batch_documents] Batching complete | {{'total_batches': {len(docs_batches)}, 'total_documents': {len(documents)}}}"
     )
-    docs_batches = []
-    start, stop = 0, batch_size
-    while True:
-        view = documents[start:stop]
-        if not view:
-            break
-        batch_docs = document_crud.read_each(view)
-        docs_batches.append(batch_docs)
-        start = stop
-        stop += batch_size
+
     return docs_batches
 
 
diff --git a/backend/app/services/collections/providers/base.py b/backend/app/services/collections/providers/base.py
index d76fb6189..36283d1fa 100644
--- a/backend/app/services/collections/providers/base.py
+++ b/backend/app/services/collections/providers/base.py
@@ -1,9 +1,8 @@
 from abc import ABC, abstractmethod
 from typing import Any
 
-from app.crud import DocumentCrud
 from app.core.cloud.storage import CloudStorage
-from app.models import CreationRequest, Collection
+from app.models import CreationRequest, Collection, Document
 
 
 class BaseProvider(ABC):
@@ -32,21 +31,17 @@ def create(
         self,
         collection_request: CreationRequest,
         storage: CloudStorage,
-        document_crud: DocumentCrud,
+        documents: list[Document],
     ) -> Collection:
         """Create collection with documents and optionally an assistant.
 
         Args:
             collection_request: Collection parameters (name, description, document list, etc.)
             storage: Cloud storage instance for file access
-            document_crud: DocumentCrud instance for fetching documents
-            batch_size: Number of documents to process per batch
-            with_assistant: Whether to create an assistant/agent
-            assistant_options: Options for assistant creation (provider-specific)
+            documents: Pre-fetched list of Document objects to add to the collection
 
         Returns:
-            llm_service_id: ID of the resource to delete
-            llm_service_name: Name of the service (determines resource type)
+            Collection object with llm_service_id and llm_service_name populated
         """
         raise NotImplementedError("Providers must implement execute method")
 
diff --git a/backend/app/services/collections/providers/openai.py b/backend/app/services/collections/providers/openai.py
index bdab86d3a..b0e418efd 100644
--- a/backend/app/services/collections/providers/openai.py
+++ b/backend/app/services/collections/providers/openai.py
@@ -1,13 +1,13 @@
 import logging
+from typing import List
 
 from openai import OpenAI
 
 from app.services.collections.providers import BaseProvider
-from app.crud import DocumentCrud
 from app.core.cloud.storage import CloudStorage
 from app.crud.rag import OpenAIVectorStoreCrud, OpenAIAssistantCrud
 from app.services.collections.helpers import batch_documents, get_service_name
-from app.models import CreationRequest, Collection
+from app.models import CreationRequest, Collection, Document
 
 
 logger = logging.getLogger(__name__)
@@ -24,19 +24,13 @@ def create(
         self,
         collection_request: CreationRequest,
         storage: CloudStorage,
-        document_crud: DocumentCrud,
+        documents: List[Document],
     ) -> Collection:
         """
         Create OpenAI vector store with documents and optionally an assistant.
         """
         try:
-            # Use user-provided batch_size, default to 10 if not set
-            batch_size = collection_request.batch_size or 10
-            docs_batches = batch_documents(
-                document_crud,
-                collection_request.documents,
-                batch_size,
-            )
+            docs_batches = batch_documents(documents)
 
             vector_store_crud = OpenAIVectorStoreCrud(self.client)
             vector_store = vector_store_crud.create()
diff --git a/backend/app/services/documents/helpers.py b/backend/app/services/documents/helpers.py
index cd941eb55..22780703e 100644
--- a/backend/app/services/documents/helpers.py
+++ b/backend/app/services/documents/helpers.py
@@ -1,7 +1,7 @@
 from typing import Optional, Tuple, Iterable, Union
-from uuid import UUID
+from uuid import UUID, uuid4
 
-from fastapi import HTTPException
+from fastapi import HTTPException, UploadFile
 
 from app.services.doctransform.registry import (
     get_available_transformers,
@@ -11,6 +11,7 @@
 )
 from app.crud import DocTransformationJobCrud, DocumentCrud
 from app.services.doctransform import job as transformation_job
+from app.services.documents import provider_upload
 from app.models import (
     DocTransformJobCreate,
     TransformationStatus,
@@ -23,6 +24,26 @@
 )
 
 
+async def calculate_file_size(file: UploadFile) -> float:
+    """
+    Calculate the size of an uploaded file in kilobytes.
+
+    Args:
+        file: The uploaded file from FastAPI
+
+    Returns:
+        The size of the file in kilobytes (KB) as a whole number
+    """
+    if file.size:
+        return round(file.size / 1024)
+
+    file.file.seek(0, 2)
+    size_bytes = file.file.tell()
+    file.file.seek(0)
+
+    return round(size_bytes / 1024)
+
+
 def pre_transform_validation(
     *,
     src_filename: str,
@@ -188,3 +209,41 @@ def build_job_schemas(
             )
         )
     return out
+
+
+def schedule_provider_upload(
+    *,
+    project_id: int,
+    organization_id: int,
+    document_id: UUID,
+    providers: list[str],
+) -> dict | None:
+    """
+    Schedule a background job to upload document to provider(s).
+
+    Args:
+        project_id: Project ID
+        document_id: Document UUID to upload
+        providers: List of provider names (e.g., ["openai"])
+
+    Returns:
+        Dictionary with job information or None if no providers
+    """
+    if not providers:
+        return None
+
+    job_id = str(uuid4())
+
+    provider_upload.start_job(
+        project_id=project_id,
+        organization_id=organization_id,
+        job_id=job_id,
+        document_id=str(document_id),
+        providers=providers,
+    )
+
+    return {
+        "message": f"Document is being uploaded to provider(s): {', '.join(providers)}",
+        "job_id": job_id,
+        "providers": providers,
+    }
diff --git a/backend/app/services/documents/provider_upload.py b/backend/app/services/documents/provider_upload.py
new file mode 100644
index 000000000..db91789be
--- /dev/null
+++ b/backend/app/services/documents/provider_upload.py
@@ -0,0 +1,185 @@
+import logging
+import tempfile
+import shutil
+from pathlib import Path
+from uuid import UUID
+
+from tenacity import retry, wait_exponential, stop_after_attempt
+from sqlmodel import Session
+from asgi_correlation_id import correlation_id
+
+from app.crud.document.document import DocumentCrud
+from app.core.cloud import get_cloud_storage
+from app.core.db import engine
+from app.utils import get_openai_client
+from app.celery.utils import start_provider_upload_job as start_provider_upload_celery
+
+
+logger = logging.getLogger(__name__)
+
+
+def start_job(
+    project_id: int,
+    organization_id: int,
+    job_id: str,
+    document_id: str,
+    providers: list[str],
+) -> str:
+    """
+    Start a provider upload job.
+
+    Args:
+        project_id: Project ID
+        job_id: Job ID for tracking
+        document_id: Document UUID to upload
+        providers: List of provider names (e.g., ["openai"])
+
+    Returns:
+        Job ID
+    """
+    trace_id = correlation_id.get() or "N/A"
+
+    task_id = start_provider_upload_celery(
+        project_id=project_id,
+        organization_id=organization_id,
+        job_id=job_id,
+        trace_id=trace_id,
+        document_id=document_id,
+        providers=providers,
+    )
+
+    logger.info(
+        f"[start_job] Job scheduled for provider upload | "
+        f"job_id={job_id}, document_id={document_id}, providers={providers}, "
+        f"project_id={project_id}, task_id={task_id}"
+    )
+    return job_id
+
+
+@retry(wait=wait_exponential(multiplier=5, min=5, max=10), stop=stop_after_attempt(3))
+def execute_job(
+    project_id: int,
+    organization_id: int,
+    job_id: str,
+    document_id: str,
+    providers: list[str],
+    task_id: str,
+    task_instance,
+):
+    """
+    Execute provider upload job - uploads document to specified provider(s).
+
+    Args:
+        project_id: Project ID
+        job_id: Job ID for tracking
+        document_id: Document UUID to upload
+        providers: List of provider names (e.g., ["openai"])
+        task_id: Celery task ID
+        task_instance: Celery task instance
+    """
+    tmp_dir: Path | None = None
+
+    try:
+        doc_uuid = UUID(document_id)
+
+        logger.info(
+            "[provider_upload.execute_job] Started | "
+            f"job_id={job_id}, document_id={doc_uuid}, providers={providers}, project_id={project_id}"
+        )
+
+        with Session(engine) as db:
+            doc_crud = DocumentCrud(session=db, project_id=project_id)
+            document = doc_crud.read_one(doc_uuid)
+
+            storage = get_cloud_storage(session=db, project_id=project_id)
+
+        # Download document from storage
+        body = storage.stream(document.object_store_url)
+        tmp_dir = Path(tempfile.mkdtemp())
+        tmp_file = tmp_dir / document.fname
+
+        with open(tmp_file, "wb") as f:
+            shutil.copyfileobj(body, f)
+
+        logger.info(
+            f"[provider_upload.execute_job] Downloaded document | "
+            f"file={document.fname}, size_kb={document.file_size_kb}"
+        )
+
+        # Upload to each provider
+        results = {}
+        for provider in providers:
+            provider_lower = provider.lower()
+
+            if provider_lower == "openai":
+                try:
+                    with Session(engine) as db:
+                        client = get_openai_client(db, organization_id, project_id)
+
+                    with open(tmp_file, "rb") as f:
+                        file_object = client.files.create(
+                            file=f,
+                            purpose="user_data",
+                            expires_after={
+                                "anchor": "created_at",
+                                "seconds": 2592000,  # 30 days
+                            },
+                        )
+
+                    # Update document with OpenAI file ID
+                    with Session(engine) as db:
+                        doc_crud = DocumentCrud(session=db, project_id=project_id)
+                        document.openai_file_id = file_object.id
+                        doc_crud.update(document)
+
+                    results[provider] = {
+                        "success": True,
+                        "file_id": file_object.id,
+                        "filename": file_object.filename,
+                        "bytes": file_object.bytes,
+                        "created_at": file_object.created_at,
+                    }
+
+                    logger.info(
+                        f"[provider_upload.execute_job] Uploaded to OpenAI | "
+                        f"file_id={file_object.id}, filename={file_object.filename}, "
+                        f"document_id={doc_uuid}"
+                    )
+
+                except Exception as e:
+                    logger.error(
+                        f"[provider_upload.execute_job] Failed to upload to OpenAI | "
+                        f"error={str(e)}",
+                        exc_info=True,
+                    )
+                    results[provider] = {
+                        "success": False,
+                        "error": str(e),
+                    }
+            else:
+                logger.warning(
+                    f"[provider_upload.execute_job] Unsupported provider | provider={provider}"
+                )
+                results[provider] = {
+                    "success": False,
+                    "error": f"Unsupported provider: {provider}",
+                }
+
+        logger.info(
+            f"[provider_upload.execute_job] Completed | "
+            f"job_id={job_id}, results={results}"
+        )
+
+        return results
+
+    except Exception as e:
+        logger.error(
+            f"[provider_upload.execute_job] FAILED | "
+            f"job_id={job_id}, error={str(e)}",
+            exc_info=True,
+        )
+        raise
+
+    finally:
+        if tmp_dir and tmp_dir.exists():
+            shutil.rmtree(tmp_dir)
diff --git a/backend/app/tests/api/routes/collections/test_create_collections.py b/backend/app/tests/api/routes/collections/test_create_collections.py
index 220cb4ee8..189a79ccb 100644
--- a/backend/app/tests/api/routes/collections/test_create_collections.py
+++ b/backend/app/tests/api/routes/collections/test_create_collections.py
@@ -3,10 +3,11 @@
 from typing import Any
 
 from fastapi.testclient import TestClient
+from sqlmodel import Session
 
 from app.core.config import settings
 from app.tests.utils.auth import TestAuthContext
-from app.models import CollectionJobStatus
+from app.models import CollectionJobStatus, Document
 from app.models.collection import CreationRequest
 
 
@@ -14,19 +15,39 @@ def _extract_metadata(body: dict) -> dict | None:
     return body.get("metadata") or body.get("meta")
 
 
+def _create_test_document(
+    db: Session, project_id: int, file_size: int = 1024
+) -> Document:
+    """Helper to create a test document."""
+    doc = Document(
+        id=uuid4(),
+        fname="test_document.txt",
+        object_store_url="s3://test-bucket/test_document.txt",
+        project_id=project_id,
+        file_size=file_size,
+    )
+    db.add(doc)
+    db.commit()
+    db.refresh(doc)
+    return doc
+
+
 @patch("app.api.routes.collections.create_service.start_job")
 def test_collection_creation_with_assistant_calls_start_job_and_returns_job(
     mock_start_job: Any,
     client: TestClient,
     user_api_key_header: dict[str, str],
     user_api_key: TestAuthContext,
+    db: Session,
 ) -> None:
+    # Create a test document in the database
+    doc = _create_test_document(db, user_api_key.project_id, file_size=2048)
+
     creation_data = CreationRequest(
         model="gpt-4o",
         instructions="string",
         temperature=0.000001,
-        documents=[UUID("f3e86a17-1e6f-41ec-b020-5b08eebef928")],
-        batch_size=10,
+        documents=[doc.id],
         callback_url=None,
     )
 
@@ -67,11 +88,14 @@ def test_collection_creation_vector_only_adds_metadata_and_sets_with_assistant_f
     client: TestClient,
     user_api_key_header: dict[str, str],
     user_api_key: TestAuthContext,
+    db: Session,
 ) -> None:
+    # Create a test document in the database
+    doc = _create_test_document(db, user_api_key.project_id, file_size=5120)
+
     creation_data = CreationRequest(
         temperature=0.000001,
-        documents=[str(uuid4())],
-        batch_size=10,
+        documents=[doc.id],
         callback_url=None,
     )
 
@@ -103,13 +127,18 @@ def test_collection_creation_vector_only_adds_metadata_and_sets_with_assistant_f
 
 
 def test_collection_creation_vector_only_request_validation_error(
-    client: TestClient, user_api_key_header: dict[str, str]
+    client: TestClient,
+    user_api_key_header: dict[str, str],
+    user_api_key: TestAuthContext,
+    db: Session,
 ) -> None:
+    # Create a test document in the database
+    doc = _create_test_document(db, user_api_key.project_id)
+
     payload = {
         "model": "gpt-4o",
         "temperature": 0.000001,
-        "documents": [str(uuid4())],
-        "batch_size": 10,
+        "documents": [str(doc.id)],
         "callback_url": None,
     }
 
diff --git a/backend/app/tests/services/collections/providers/test_openai_provider.py b/backend/app/tests/services/collections/providers/test_openai_provider.py
index 8ae028a0a..25f664146 100644
--- a/backend/app/tests/services/collections/providers/test_openai_provider.py
+++ b/backend/app/tests/services/collections/providers/test_openai_provider.py
@@ -18,7 +18,6 @@ def test_create_openai_vector_store_only() -> None:
 
     collection_request = SimpleNamespace(
         documents=["doc1", "doc2"],
-        batch_size=10,
         model=None,
         instructions=None,
         temperature=None,
@@ -57,7 +56,6 @@ def test_create_openai_with_assistant() -> None:
 
     collection_request = SimpleNamespace(
         documents=["doc1"],
-        batch_size=10,
         model="gpt-4o",
         instructions="You are helpful",
         temperature=0.7,
@@ -138,7 +136,6 @@ def test_create_propagates_exception() -> None:
 
     collection_request = SimpleNamespace(
         documents=["doc1"],
-        batch_size=10,
         model=None,
         instructions=None,
         temperature=None,
diff --git a/backend/app/tests/services/collections/test_create_collection.py b/backend/app/tests/services/collections/test_create_collection.py
index db5a1b30c..4d393ea1b 100644
--- a/backend/app/tests/services/collections/test_create_collection.py
+++ b/backend/app/tests/services/collections/test_create_collection.py
@@ -58,7 +58,6 @@ def test_start_job_creates_collection_job_and_schedules_task(db: Session) -> Non
     project = get_project(db)
     request = CreationRequest(
         documents=[UUID("f3e86a17-1e6f-41ec-b020-5b08eebef928")],
-        batch_size=10,
         callback_url=None,
         provider="openai",
     )
@@ -133,7 +132,7 @@ def test_execute_job_success_flow_updates_job_and_creates_collection(
     aws.client.put_object(Bucket=settings.AWS_S3_BUCKET, Key=str(s3_key), Body=b"test")
 
     sample_request = CreationRequest(
-        documents=[document.id], batch_size=10, callback_url=None, provider="openai"
+        documents=[document.id], callback_url=None, provider="openai"
     )
 
     mock_get_llm_provider.return_value = get_mock_provider(
@@ -200,9 +199,7 @@ def test_execute_job_assistant_create_failure_marks_failed_and_deletes_collectio
         collection_id=None,
     )
 
-    req = CreationRequest(
-        documents=[], batch_size=10, callback_url=None, provider="openai"
-    )
+    req = CreationRequest(documents=[], callback_url=None, provider="openai")
 
     mock_provider = get_mock_provider(
         llm_service_id="vs_123", llm_service_name="openai vector store"
@@ -265,7 +262,6 @@ def test_execute_job_success_flow_callback_job_and_creates_collection(
 
     sample_request = CreationRequest(
         documents=[document.id],
-        batch_size=10,
         callback_url=callback_url,
         provider="openai",
     )
@@ -346,7 +342,6 @@ def test_execute_job_success_creates_collection_with_callback(
 
     sample_request = CreationRequest(
         documents=[document.id],
-        batch_size=10,
         callback_url=callback_url,
         provider="openai",
     )
@@ -430,7 +425,6 @@ def test_execute_job_failure_flow_callback_job_and_marks_failed(
 
     sample_request = CreationRequest(
         documents=[uuid.uuid4()],
-        batch_size=10,
         callback_url=callback_url,
         provider="openai",
     )
diff --git a/backend/app/tests/services/collections/test_helpers.py b/backend/app/tests/services/collections/test_helpers.py
index f53271f18..7cddaf305 100644
--- a/backend/app/tests/services/collections/test_helpers.py
+++ b/backend/app/tests/services/collections/test_helpers.py
@@ -11,7 +11,13 @@
 from app.services.collections import helpers
 from app.tests.utils.utils import get_project
 from app.tests.utils.collection import get_vector_store_collection
-from app.services.collections.helpers import ensure_unique_name
+from app.services.collections.helpers import (
+    ensure_unique_name,
+    get_service_name,
+    to_collection_public,
+)
+from app.models import Collection, ProviderType
+from app.core.util import now
 
 
 def test_extract_error_message_parses_json_and_strips_prefix() -> None:
@@ -45,49 +51,91 @@ def test_extract_error_message_handles_non_matching_bodies() -> None:
 # batch documents
 
 
-class FakeDocumentCrud:
-    def __init__(self):
-        self.calls = []
+def create_fake_documents(
+    count: int, file_size_kb: float | None = 1
+) -> list[SimpleNamespace]:
+    """Create fake document objects for testing.
+
+    Args:
+        count: Number of documents to create
+        file_size_kb: Size in KB for each document (default 1 KB)
+
+    Returns:
+        List of SimpleNamespace objects mimicking Document objects
+    """
+    return [
+        SimpleNamespace(
+            id=uuid4(),
+            fname=f"doc_{i}.txt",
+            object_store_url=f"s3://bucket/doc_{i}.txt",
+            file_size_kb=file_size_kb,
+        )
+        for i in range(count)
+    ]
+
+
+def test_batch_documents_small_files_single_batch() -> None:
+    """Test that small files all fit in one batch (under 30 MB and under 200 docs)."""
+    docs = create_fake_documents(6, file_size_kb=1)  # 1 KB per file
+    batches = helpers.batch_documents(docs)
+
+    # All 6 small files should fit in one batch
+    assert len(batches) == 1
+    assert len(batches[0]) == 6
+    assert [d.id for d in batches[0]] == [d.id for d in docs]
 
-    def read_each(self, ids):
-        self.calls.append(list(ids))
-        return [
-            SimpleNamespace(
-                id=i, fname=f"{i}.txt", object_store_url=f"s3://bucket/{i}.txt"
-            )
-            for i in ids
-        ]
 
+def test_batch_documents_size_based_batching() -> None:
+    """Test that large files trigger size-based batching (30 MB limit)."""
+    # Each file is 20 MB (20480 KB), so max 1 file per batch (since 2 * 20 MB > 30 MB)
+    docs = create_fake_documents(3, file_size_kb=20 * 1024)
+    batches = helpers.batch_documents(docs)
 
-def test_batch_documents_even_chunks() -> None:
-    crud = FakeDocumentCrud()
-    ids = [uuid4() for _ in range(6)]
-    batches = helpers.batch_documents(crud, ids, batch_size=3)
+    # Should create 3 batches, one for each 20 MB file
+    assert len(batches) == 3
+    assert len(batches[0]) == 1
+    assert len(batches[1]) == 1
+    assert len(batches[2]) == 1
 
-    # read_each called with chunks [0:3], [3:6]
-    assert crud.calls == [ids[0:3], ids[3:6]]
-    # output mirrors what read_each returned
+
+def test_batch_documents_count_based_batching() -> None:
+    """Test that document count triggers batching (200 docs limit)."""
+    docs = create_fake_documents(250, file_size_kb=0.1)  # Small files
+    batches = helpers.batch_documents(docs)
+
+    # Should create 2 batches: 200 + 50
     assert len(batches) == 2
-    assert [d.id for d in batches[0]] == ids[0:3]
-    assert [d.id for d in batches[1]] == ids[3:6]
+    assert len(batches[0]) == 200
+    assert len(batches[1]) == 50
+
 
+def test_batch_documents_mixed_size_batching() -> None:
+    """Test batching with files that fit multiple per batch but hit 30 MB limit."""
+    # Each file is 15 MB (15360 KB), so 2 files = 30 MB (at limit), 3 files > 30 MB
+    docs = create_fake_documents(5, file_size_kb=15 * 1024)
+    batches = helpers.batch_documents(docs)
 
-def test_batch_documents_ragged_last_chunk() -> None:
-    crud = FakeDocumentCrud()
-    ids = [uuid4() for _ in range(5)]
-    batches = helpers.batch_documents(crud, ids, batch_size=2)
+    # Should create 3 batches: [2 files, 2 files, 1 file]
+    assert len(batches) == 3
+    assert len(batches[0]) == 2  # 30 MB total
+    assert len(batches[1]) == 2  # 30 MB total
+    assert len(batches[2]) == 1  # 15 MB total
 
-    assert crud.calls == [ids[0:2], ids[2:4], ids[4:5]]
-    assert [d.id for d in batches[0]] == ids[0:2]
-    assert [d.id for d in batches[1]] == ids[2:4]
-    assert [d.id for d in batches[2]] == ids[4:5]
+
+def test_batch_documents_with_none_file_size() -> None:
+    """Test that documents with None file_size are treated as 0 bytes."""
+    docs = create_fake_documents(10, file_size_kb=None)
+    batches = helpers.batch_documents(docs)
+
+    # All files with None/0 size should fit in one batch (under both limits)
+    assert len(batches) == 1
+    assert len(batches[0]) == 10
 
 
 def test_batch_documents_empty_input() -> None:
-    crud = FakeDocumentCrud()
-    batches = helpers.batch_documents(crud, [], batch_size=3)
+    """Test that empty input returns empty batches."""
+    batches = helpers.batch_documents([])
     assert batches == []
-    assert crud.calls == []
 
 
 def test_ensure_unique_name_success(db: Session) -> None:
@@ -125,3 +173,109 @@ def test_ensure_unique_name_conflict_with_vector_store_collection(db: Session) -
 
     assert exc.value.status_code == 409
     assert "already exists" in exc.value.detail
+
+
+# get_service_name
+
+
+def test_get_service_name_openai() -> None:
+    """Test that OpenAI provider returns correct service name."""
+    result = get_service_name("openai")
+    assert result == "openai vector store"
+
+
+def test_get_service_name_case_insensitive() -> None:
+    """Test that provider name is case-insensitive."""
+    assert get_service_name("OpenAI") == "openai vector store"
+    assert get_service_name("OPENAI") == "openai vector store"
+    assert get_service_name("OpEnAi") == "openai vector store"
+
+
+def test_get_service_name_unknown_provider() -> None:
+    """Test that unknown providers return empty string."""
+    assert get_service_name("unknown") == ""
+    assert get_service_name("bedrock") == ""  # Commented out in the mapping
+    assert get_service_name("gemini") == ""  # Commented out in the mapping
+    assert get_service_name("") == ""
+
+
+# to_collection_public
+
+
+def test_to_collection_public_vector_store() -> None:
+    """Test conversion of vector store collection to public model."""
+    collection = Collection(
+        id=uuid4(),
+        project_id=1,
+        provider=ProviderType.openai,
+        llm_service_id="vs_123",
+        llm_service_name="openai vector store",  # Matches get_service_name("openai")
+        name="Test Collection",
+        description="Test description",
+        inserted_at=now(),
+        updated_at=now(),
+        deleted_at=None,
+    )
+
+    result = to_collection_public(collection)
+
+    # For vector store, should map to knowledge_base fields
+    assert result.id == collection.id
+    assert result.knowledge_base_id == "vs_123"
+    assert result.knowledge_base_provider == "openai vector store"
+    assert result.llm_service_id is None
+    assert result.llm_service_name is None
+    assert result.project_id == 1
+    assert result.inserted_at == collection.inserted_at
+    assert result.updated_at == collection.updated_at
+    assert result.deleted_at is None
+
+
+def test_to_collection_public_assistant() -> None:
+    """Test conversion of assistant collection to public model."""
+    collection = Collection(
+        id=uuid4(),
+        project_id=2,
+        provider=ProviderType.openai,
+        llm_service_id="asst_456",
+        llm_service_name="gpt-4",  # Does NOT match vector store name
+        name="Assistant Collection",
+        description="Assistant description",
+        inserted_at=now(),
+        updated_at=now(),
+        deleted_at=None,
+    )
+
+    result = to_collection_public(collection)
+
+    # For assistant, should map to llm_service fields
+    assert result.id == collection.id
+    assert result.llm_service_id == "asst_456"
+    assert result.llm_service_name == "gpt-4"
+    assert result.knowledge_base_id is None
+    assert result.knowledge_base_provider is None
+    assert result.project_id == 2
+    assert result.inserted_at == collection.inserted_at
+    assert result.updated_at == collection.updated_at
+    assert result.deleted_at is None
+
+
+def test_to_collection_public_with_deleted_at() -> None:
+    """Test that deleted_at field is properly included when set."""
+    deleted_time = now()
+    collection = Collection(
+        id=uuid4(),
+        project_id=3,
+        provider=ProviderType.openai,
+        llm_service_id="vs_789",
+        llm_service_name="openai vector store",
+        name="Deleted Collection",
+        description="Deleted",
+        inserted_at=now(),
+        updated_at=now(),
+        deleted_at=deleted_time,
+    )
+
+    result = to_collection_public(collection)
+
+    assert result.deleted_at == deleted_time
