› cavi-ai/
GitHub ↗

Own provider file IDs and their cleanup

There is no provider-neutral files API in this release. File IDs, uploads, retention, and prompt references belong to the provider. Never reuse a file ID from one backend on another.

Use the narrow Codex file entry on your server. Configure a reusable CodexFilesClient with your server-owned API key and request timeout, then pass it to these helpers. They upload prepared OpenAI batch JSONL and remove a saved file ID in a later worker, without relying on an in-memory cleanup callback.

Download batch-files.ts. Both helpers borrow the file client; they do not create or dispose a runtime client.

ts
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType } from "@cavi-ai/api-client";
import type { CodexFilesClient } from "@cavi-ai/api-client/providers/codex/files";

export async function uploadBatchInput(files: CodexFilesClient, jsonl: string) {
  if (!jsonl.trim()) throw new ApiClientError("Batch input is required.", {
    type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
  });
  const file = await files.uploadFile(jsonl, "batch", "input.jsonl");
  if (typeof file?.id !== "string" || !file.id.trim()) {
    throw new ApiClientError("Upload returned no usable file ID.", {
      code: ApiClientErrorCode.ProtocolMismatch, cause: file,
    });
  }
  return file;
}

export async function removeBatchInput(files: CodexFilesClient, fileId: string) {
  if (!fileId.trim()) throw new ApiClientError("A saved file ID is required.", {
    type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
  });
  const receipt = await files.deleteFile(fileId);
  if (receipt?.id !== fileId || receipt?.deleted !== true) {
    throw new ApiClientError("Deletion was not confirmed for the saved file.", {
      code: ApiClientErrorCode.ProtocolMismatch, cause: { fileId, receipt },
    });
  }
  return { id: fileId, deleted: true as const };
}

The JSONL must follow the provider's batch wire format; a serialized RuntimeBatchRequest[] is not that format. Prefer the facade's batch submission for its built-in request conversion.

Retain the ID with the owning job. Delete only after the provider no longer needs the file and your retention policy allows it. Disposing a runtime client does not delete uploaded files.

Keep cleanup explicit and durable

StepApplication responsibility
UploadSave the returned file ID and metadata with the job, provider, credential scope, and application owner
UseKeep the file while the provider still needs it; runtime client shutdown is separate
Authorize cleanupLoad the saved record and check ownership and retention before passing its ID to removeBatchInput
DeleteMark cleanup confirmed only after the helper returns its receipt
Cleanup failureRetain the ID and failure state for reconciliation; do not mark it deleted

The deletion helper requires deleted: true for the same saved ID. An absent, false, or mismatched acknowledgment raises ApiClientError with ProtocolMismatch, retaining the file ID and receipt in cause. Blank input or IDs raise InvalidRequest before a provider request. These are application checks; the underlying SDK file methods remain unchanged.

File methods are provider-specific raw APIs. HTTP, authentication, and unknown failures reject rather than returning facade gaps. Keep original diagnostics in protected telemetry and show application-owned messages to callers. A 404 does not satisfy this helper's confirmation policy; decide separately how your job system reconciles a file that is already absent.

Do not delete in an unconditional finally immediately after uploading input for an asynchronous batch. The remote job can outlive that request. If an upload response is lost or has no usable ID, do not replay it automatically: an uploaded file may already exist and require provider-side reconciliation. Failure to persist an acknowledged ID also needs reconciliation.

Run the file lifecycle tests

Download file-tests.ts beside the helpers in examples/. Use the ESM workspace and installation from consumer testing, then run:

sh
npx tsc --target ES2022 --module NodeNext --moduleResolution NodeNext \
  --strict --skipLibCheck --types node --outDir .file-tests \
  examples/file-tests.ts examples/batch-files.ts
node --test .file-tests/file-tests.js

Expected result: seven passing tests without real credentials or network calls. HTTP fixtures exercise the installed public file client, multipart input, metadata retention, cleanup from a saved ID, invalid acknowledgments, and authentication/availability failures. The file client has no owned socket to close; each fixture creates an isolated client and finite HTTP responses.

Codex file methods · Gateway workspace access

Referenced examples

Verified example · TypeScript

Batch files

examples/batch-files.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType } from "@cavi-ai/api-client";
import type { CodexFilesClient } from "@cavi-ai/api-client/providers/codex/files";

export async function uploadBatchInput(files: CodexFilesClient, jsonl: string) {
  if (!jsonl.trim()) throw new ApiClientError("Batch input is required.", {
    type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
  });
  const file = await files.uploadFile(jsonl, "batch", "input.jsonl");
  if (typeof file?.id !== "string" || !file.id.trim()) {
    throw new ApiClientError("Upload returned no usable file ID.", {
      code: ApiClientErrorCode.ProtocolMismatch, cause: file,
    });
  }
  return file;
}

export async function removeBatchInput(files: CodexFilesClient, fileId: string) {
  if (!fileId.trim()) throw new ApiClientError("A saved file ID is required.", {
    type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
  });
  const receipt = await files.deleteFile(fileId);
  if (receipt?.id !== fileId || receipt?.deleted !== true) {
    throw new ApiClientError("Deletion was not confirmed for the saved file.", {
      code: ApiClientErrorCode.ProtocolMismatch, cause: { fileId, receipt },
    });
  }
  return { id: fileId, deleted: true as const };
}

Package imports

Referenced by

Artifact provenance
Package
@cavi-ai/api-client@0.21.0
Release
v0.21.0
Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1
Source
examples/batch-files.ts
Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558
Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48

Verified example · TypeScript

File tests

examples/file-tests.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import assert from "node:assert/strict";
import { test } from "node:test";
import { isApiClientError, isAuthError } from "@cavi-ai/api-client";
import { CodexFilesClient } from "@cavi-ai/api-client/providers/codex/files";
import { uploadBatchInput, removeBatchInput } from "./batch-files.js";

function fixture(reply: (url: string, init?: RequestInit) => Response) {
  const calls: { url: string; init?: RequestInit }[] = [];
  const files = new CodexFilesClient({ apiKey: "fixture-key", defaultTimeoutMs: 1_000,
    fetchImpl: async (url, init) => {
      calls.push({ url: String(url), init });
      return reply(String(url), init);
    },
  });
  return { files, calls };
}

test("uploads provider JSONL as multipart and retains file metadata without deleting", async () => {
  const app = fixture(() => Response.json({ id: "file-1", object: "file", bytes: 12 }));
  const jsonl = '{"custom_id":"one","method":"POST","url":"/v1/responses","body":{"model":"server-model","input":"Question"}}';
  const file = await uploadBatchInput(app.files, jsonl);
  assert.deepEqual(file, { id: "file-1", object: "file", bytes: 12 });
  assert.equal(app.calls.length, 1);
  const { url, init } = app.calls[0];
  assert.equal(url, "https://api.openai.com/v1/files");
  assert.equal(init?.method, "POST");
  assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-key");
  assert.ok(init?.body instanceof FormData);
  assert.equal(init.body.get("purpose"), "batch");
  const content = init.body.get("file");
  assert.ok(content instanceof File);
  assert.equal(content.name, "input.jsonl");
  assert.equal(await content.text(), jsonl);
});

test("cleans up a persisted ID through a newly configured worker client", async () => {
  const app = fixture(() => Response.json({ id: "file-1", deleted: true }));
  const receipt = await removeBatchInput(app.files, "file-1");
  assert.deepEqual(receipt, { id: "file-1", deleted: true });
  assert.equal(app.calls.length, 1);
  assert.equal(app.calls[0].url, "https://api.openai.com/v1/files/file-1");
  assert.equal(app.calls[0].init?.method, "DELETE");
});

test("rejects blank input or IDs before provider work", async () => {
  const app = fixture(() => { throw new Error("Invalid input reached the provider"); });
  for (const value of ["", " ", "\n"]) {
    await assert.rejects(uploadBatchInput(app.files, value), { code: "invalid_request" });
    await assert.rejects(removeBatchInput(app.files, value), { code: "invalid_request" });
  }
  assert.equal(app.calls.length, 0);
});

test("rejects upload responses without a usable file ID", async () => {
  for (const reply of [null, {}, { id: "" }, { id: " " }]) {
    const app = fixture(() => Response.json(reply));
    await assert.rejects(uploadBatchInput(app.files, "{}"), { code: "protocol_mismatch" });
    assert.equal(app.calls.length, 1, "an ambiguous upload must not be replayed or deleted automatically");
  }
});

test("requires deletion confirmation for the same saved ID", async () => {
  for (const reply of [null, { id: "file-1", deleted: false }, { id: "foreign", deleted: true }, { deleted: true }]) {
    const app = fixture(() => Response.json(reply));
    await assert.rejects(removeBatchInput(app.files, "file-1"), (error) => {
      assert.ok(isApiClientError(error));
      assert.equal(error.code, "protocol_mismatch");
      assert.equal((error.cause as { fileId: string }).fileId, "file-1");
      return true;
    });
    assert.equal(app.calls.length, 1);
  }
});

test("retains failed cleanup as an exception rather than reporting deletion", async () => {
  const app = fixture(() => Response.json({}, { status: 503 }));
  await assert.rejects(removeBatchInput(app.files, "file-1"), isApiClientError);
  assert.equal(app.calls.length, 1);
});

test("keeps authentication failures as exceptions", async () => {
  const app = fixture(() => Response.json({}, { status: 401 }));
  await assert.rejects(uploadBatchInput(app.files, "{}"), isAuthError);
  assert.equal(app.calls.length, 1);
});

Package imports

Referenced by

Artifact provenance
Package
@cavi-ai/api-client@0.21.0
Release
v0.21.0
Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1
Source
examples/file-tests.ts
Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558
Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48