› cavi-ai/
GitHub ↗

Collect a batch without losing individual failures

Batching is for asynchronous groups of requests. Claude Messages and Codex declare batch support; Managed Agents, AGY, OpenCode, and gateways do not.

Give every request a unique customId from your application's record identity. Submit bodies with provider-compatible models/tools, then persist the returned batch_id with its owner. Do not correlate responses by array position.

Resume collection in a later worker

Download batch-collector.ts. The function below takes a saved ID and performs one collection attempt. It distinguishes active work, terminal work without available results, and retrievable items. It does not submit, cancel, or poll in a loop. The application owns the client.

Authorize access to the saved batch before calling it; a provider credential does not establish which application user owns the batch.

ts
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType, type CapabilityClient, type RuntimeBatchResult } from "@cavi-ai/api-client";

export async function collectBatch(client: CapabilityClient, batchId: string) {
  const status = await client.getBatch(batchId);
  if (!status.ok) return status;
  const batch = status.data;
  if (!status.data.resultsAvailable) {
    const kind = ["completed", "failed", "cancelled", "expired"].includes(batch.status)
      ? "terminal" as const : "pending" as const;
    return { ok: true as const, source: status.source, data: { kind, batch } };
  }
  const results = await client.getBatchResults(batchId);
  if (!results.ok) return results;
  const items = new Map<string, RuntimeBatchResult>();
  for (const item of results.data) {
    if (!item.customId.trim() || items.has(item.customId)) {
      throw new ApiClientError("Batch results have missing or duplicate correlation IDs.", {
        type: ApiClientErrorType.Validation, code: ApiClientErrorCode.ProtocolMismatch,
        cause: { batch, customId: item.customId },
      });
    }
    items.set(item.customId, item);
  }
  return {
    ok: true as const,
    source: results.source,
    data: {
      kind: "results" as const,
      batch,
      items,
    },
  };
}
ResultWorker action
ok: falseRetain the saved ID and handle the original facade gap; do not submit again
pendingSave batch and schedule another authorized collection attempt
terminalSave batch and stop normal polling; no result artifact was advertised
resultsRecord each item by its correlation ID, retaining failures

Known terminal states are completed, failed, cancelled, and Codex's expired. Check resultsAvailable first: a cancelled or expired batch can still expose useful results. A completed batch without an advertised artifact does not become an empty successful result. Unknown provider states remain pending; bound retries and escalate them through your application's job policy.

For collected items, inspect each outcome. A succeeded item can carry a run result; inspect its run state and required output before calling it an answer. Text remains optional. Keep errored, canceled, expired, and unknown item outcomes in your reporting. The batch state uses cancelled; item outcomes use canceled.

This helper requires nonblank, unique result IDs. It throws ApiClientError with ProtocolMismatch if correlation is ambiguous, retaining the batch in cause rather than overwriting a prior item. Compare returned IDs with the request IDs you persisted: this check does not prove that every submitted request has a returned result. Keep missing IDs visible and reconcile them.

Retrieval gaps remain facade results; authentication and unclassified failures still reject. Store provider diagnostics in protected telemetry. A retry hint does not establish that resubmitting a failed item or batch is safe.

Run the collection tests

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

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

Expected result: eight passing tests without provider credentials or network calls. Native Claude and Codex HTTP fixtures exercise the installed adapters, facade, and collector. They check terminal states, out-of-order mixed outcomes, ambiguous IDs, retrieval gaps, authentication, and retrieval-only requests. Each test disposes its client through the test cleanup hook.

Submit and wait locally when appropriate

The submission and bounded-wait helper accepts your request array and returns every result. It preserves the batch ID in the error if its poll budget expires; stopping that wait does not cancel the backend batch. It borrows the client.

Codex uses uploaded JSONL and result files; Claude uses Message Batches. Provider limits and asynchronous timing differ. The normalized request/result contract does not remove those limits.

Facade callers inspect result.ok; raw callers must check both runtimeSupports(capabilities, "batch") and optional method presence. Raw guard · Batch methods · Errors

Referenced examples

Verified example · TypeScript

Batch collector

examples/batch-collector.ts

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

export async function collectBatch(client: CapabilityClient, batchId: string) {
  const status = await client.getBatch(batchId);
  if (!status.ok) return status;
  const batch = status.data;
  if (!status.data.resultsAvailable) {
    const kind = ["completed", "failed", "cancelled", "expired"].includes(batch.status)
      ? "terminal" as const : "pending" as const;
    return { ok: true as const, source: status.source, data: { kind, batch } };
  }
  const results = await client.getBatchResults(batchId);
  if (!results.ok) return results;
  const items = new Map<string, RuntimeBatchResult>();
  for (const item of results.data) {
    if (!item.customId.trim() || items.has(item.customId)) {
      throw new ApiClientError("Batch results have missing or duplicate correlation IDs.", {
        type: ApiClientErrorType.Validation, code: ApiClientErrorCode.ProtocolMismatch,
        cause: { batch, customId: item.customId },
      });
    }
    items.set(item.customId, item);
  }
  return {
    ok: true as const,
    source: results.source,
    data: {
      kind: "results" as const,
      batch,
      items,
    },
  };
}

Package imports

Referenced by

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

Verified example · TypeScript

Batch tests

examples/batch-tests.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
import { createApiClient, createRuntimeProviderRegistry, isApiClientError, isAuthError } from "@cavi-ai/api-client";
import { createClaudeProviderModule } from "@cavi-ai/api-client/providers/claude/messages";
import { createCodexProviderModule } from "@cavi-ai/api-client/providers/codex/runtime";
import { collectBatch } from "./batch-collector.js";

function fixture(t: TestContext, provider: "claude" | "codex", reply: (url: string) => Response) {
  const requests: string[] = [];
  const client = createApiClient(provider, {
    registry: createRuntimeProviderRegistry({ modules: [provider === "claude"
      ? createClaudeProviderModule({ apiKey: "fixture-key" })
      : createCodexProviderModule({ apiKey: "fixture-key" })] }),
    fetchImpl: async (url, init) => {
      assert.equal(init?.method ?? "GET", "GET", "resuming must never submit or cancel work");
      requests.push(String(url));
      return reply(String(url));
    },
  });
  t.after(() => client.dispose());
  return { client, requests };
}

test("keeps an active batch pending without fetching result files", async (t) => {
  const app = fixture(t, "codex", () => Response.json({ id: "saved-1", status: "in_progress" }));
  const result = await collectBatch(app.client, "saved-1");
  assert.equal(result.ok, true);
  if (!result.ok) return;
  assert.equal(result.data.kind, "pending");
  assert.equal(result.data.batch.batch_id, "saved-1");
  assert.equal(app.requests.length, 1);
});

test("stops rescheduling known terminal states with no available results", async (t) => {
  for (const status of ["failed", "cancelled", "expired", "completed"]) {
    const app = fixture(t, "codex", () => Response.json({ id: "saved-1", status }));
    const result = await collectBatch(app.client, "saved-1");
    assert.equal(result.ok, true);
    if (!result.ok) continue;
    assert.equal(result.data.kind, "terminal", status);
    assert.equal(result.data.batch.status, status);
    assert.equal(app.requests.length, 1);
  }
});

test("collects available results even when the batch was cancelled", async (t) => {
  const app = fixture(t, "codex", (url) => url.endsWith("/content")
    ? new Response(JSON.stringify({ custom_id: "one", response: { status_code: 200,
      body: { id: "answer-1", status: "completed", output_text: "Retained answer" },
    } }))
    : Response.json({ id: "saved-1", status: "cancelled", output_file_id: "output-1" }));
  const result = await collectBatch(app.client, "saved-1");
  assert.equal(result.ok, true);
  if (!result.ok || result.data.kind !== "results") return assert.fail("Expected available results");
  assert.equal(result.source, "live");
  assert.equal(result.data.batch.status, "cancelled");
  assert.equal(result.data.items.get("one")?.run?.output, "Retained answer");
  assert.ok(app.requests.every((url) => url.endsWith("/batches/saved-1") || url.endsWith("/files/output-1/content")));
});

function claudeResults(items: unknown[]) {
  return (url: string) => url.endsWith("/results")
    ? new Response(items.map((item) => JSON.stringify(item)).join("\n"))
    : Response.json({ id: "saved-1", processing_status: "ended" });
}

test("correlates out-of-order items and retains every outcome", async (t) => {
  const app = fixture(t, "claude", claudeResults([
    { custom_id: "two", result: { type: "errored", error: { message: "Private item diagnostic" } } },
    { custom_id: "one", result: { type: "succeeded", message: { id: "answer-1", content: [{ type: "text", text: "Answer" }], stop_reason: "end_turn" } } },
    { custom_id: "three", result: { type: "canceled" } },
    { custom_id: "four", result: { type: "expired" } },
    { custom_id: "five", result: { type: "future-outcome" } },
  ]));
  const result = await collectBatch(app.client, "saved-1");
  assert.equal(result.ok, true);
  if (!result.ok || result.data.kind !== "results") return assert.fail("Expected results");
  assert.equal(result.data.batch.batch_id, "saved-1");
  assert.equal(result.data.items.size, 5);
  assert.equal(result.data.items.get("one")?.run?.output, "Answer");
  assert.equal(result.data.items.get("two")?.outcome, "errored");
  assert.equal(result.data.items.get("three")?.outcome, "canceled");
  assert.equal(result.data.items.get("four")?.outcome, "expired");
  assert.equal(result.data.items.get("five")?.outcome, "future-outcome");
});

test("rejects missing or duplicated correlation IDs without overwriting items", async (t) => {
  for (const ids of [["same", "same"], [""], [undefined]]) {
    const app = fixture(t, "claude", claudeResults(ids.map((id) => ({ custom_id: id, result: { type: "canceled" } }))));
    await assert.rejects(collectBatch(app.client, "saved-1"), (error) => {
      assert.ok(isApiClientError(error));
      assert.equal(error.code, "protocol_mismatch");
      assert.equal((error.cause as { batch: { batch_id: string } }).batch.batch_id, "saved-1");
      return true;
    });
  }
});

test("preserves a status retrieval gap", async (t) => {
  const app = fixture(t, "codex", () => Response.json({}, { status: 503 }));
  const result = await collectBatch(app.client, "saved-1");
  assert.equal(result.ok, false);
  if (result.ok) return;
  assert.equal(result.gap.reason, "backend-unavailable");
  assert.equal(app.requests.length, 1);
});

test("preserves a result retrieval gap without reporting empty success", async (t) => {
  const app = fixture(t, "claude", (url) => url.endsWith("/results")
    ? Response.json({}, { status: 503 })
    : Response.json({ id: "saved-1", processing_status: "ended" }));
  const result = await collectBatch(app.client, "saved-1");
  assert.equal(result.ok, false);
  if (result.ok) return;
  assert.equal(result.gap.reason, "backend-unavailable");
  assert.equal(app.requests.length, 2);
});

test("keeps authentication failures as exceptions", async (t) => {
  const app = fixture(t, "codex", () => Response.json({}, { status: 401 }));
  await assert.rejects(collectBatch(app.client, "saved-1"), isAuthError);
  assert.equal(app.requests.length, 1);
});

Package imports

Referenced by

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

Verified example · TypeScript

Runtime batch

examples/runtime-batch.ts

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

export async function submitBatchAndCollect(
  client: CapabilityClient,
  requests: RuntimeBatchRequest[],
  options: { maxPolls?: number; pollIntervalMs?: number } = {},
) {
  const { maxPolls = 60, pollIntervalMs = 60_000 } = options;
  if (!Number.isInteger(maxPolls) || maxPolls < 0 || !Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) {
    throw new ApiClientError("Use a non-negative poll count and interval.", {
      type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
    });
  }
  const submitted = await client.submitBatch(requests);
  if (!submitted.ok) throw new ApiClientError(submitted.gap.note, {
    code: ApiClientErrorCode.RequestFailed, cause: submitted.gap,
  });
  let batch = submitted.data;
  for (let poll = 0; !batch.resultsAvailable && poll < maxPolls; poll += 1) {
    if (["failed", "cancelled"].includes(batch.status)) break;
    await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
    const next = await client.getBatch(batch.batch_id);
    if (!next.ok) throw new ApiClientError(next.gap.note, {
      code: ApiClientErrorCode.RequestFailed, cause: next.gap,
    });
    batch = next.data;
  }
  if (!batch.resultsAvailable) {
    // Keep the ID: stopping this local wait does not cancel the backend batch.
    throw new ApiClientError(`Batch ${batch.batch_id} is not ready (status: ${batch.status}); retrieve it later or cancel explicitly.`, {
      code: ApiClientErrorCode.RequestFailed, cause: batch,
    });
  }
  const results = await client.getBatchResults(batch.batch_id);
  if (!results.ok) throw new ApiClientError(results.gap.note, {
    code: ApiClientErrorCode.RequestFailed, cause: results.gap,
  });
  // Callers must inspect each item's outcome, including errored/canceled/expired.
  return results.data;
}

Package imports

Referenced by

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

Verified example · TypeScript

Runtime capabilities

examples/runtime-capabilities.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import { runtimeSupports, type RuntimeClient, type RuntimeBatchRequest } from "@cavi-ai/api-client";

export async function submitWhenBatchIsAvailable(client: RuntimeClient, requests: RuntimeBatchRequest[]) {
  const capabilities = await client.getRuntimeCapabilities();
  if (!runtimeSupports(capabilities, "batch") || !client.submitBatch) return null;
  return client.submitBatch(requests);
}

Package imports

Referenced by

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