Tool dalam Vercel AI SDK bukan sekadar fungsi yang dipanggil model. Ia adalah batas API yang menerima input dari sistem probabilistik, lalu dapat membaca atau mengubah data penting. Karena itu, kontrak tool harus memvalidasi input, memeriksa identitas pengguna, mengotorisasi setiap pemanggilan, menghasilkan error yang dapat dipahami model maupun aplikasi, serta mencegah mutasi dieksekusi dua kali saat terjadi retry.

Pola yang aman adalah memisahkan tiga lapisan: schema kontrak untuk bentuk data, policy runtime untuk autentikasi dan otorisasi, serta executor untuk pekerjaan bisnis. Vercel Function menjadi boundary HTTP yang membuat konteks request, sedangkan tool Vercel AI SDK memakai kontrak yang sama ketika dipanggil oleh model.

1. Bentuk kontrak tool yang tahan perubahan

Kontrak tool sebaiknya mendefinisikan input dan output yang stabil. Jangan mengekspos objek database langsung kepada model karena perubahan kolom, informasi sensitif, atau perbedaan versi internal dapat menjadi perubahan kontrak yang tidak disengaja.

Contoh kontrak input-output

import { z } from "zod";

export const createTicketInput = z.object({
  title: z.string().trim().min(1).max(120),
  description: z.string().trim().min(1).max(5000),
  priority: z.enum(["low", "normal", "high"]).default("normal"),
  clientMutationId: z.string().uuid()
});

export const ticketOutput = z.object({
  ticket: z.object({
    id: z.string(),
    title: z.string(),
    status: z.enum(["open", "closed"]),
    priority: z.enum(["low", "normal", "high"])
  }),
  requestId: z.string(),
  replayed: z.boolean()
});

type CreateTicketInput = z.infer<typeof createTicketInput>;
type TicketOutput = z.infer<typeof ticketOutput>;

clientMutationId bukan pengganti idempotency key HTTP, tetapi dapat menjadi identitas mutasi dari sisi percakapan atau klien. Keduanya dapat dipetakan ke kunci idempotensi internal. Jika tool dipanggil ulang dengan tujuan yang sama, sistem dapat mengembalikan hasil sebelumnya tanpa membuat tiket baru.

Gunakan perubahan kontrak yang kompatibel jika memungkinkan. Menambah field output biasanya lebih aman daripada menghapus atau mengganti arti field. Untuk input, field baru sebaiknya opsional terlebih dahulu. Jika perubahan breaking memang diperlukan, gunakan versi tool atau nama operasi baru, bukan mengubah arti parameter lama secara diam-diam.

2. Validasi schema dengan Zod

Validasi harus dilakukan di boundary, sebelum data diteruskan ke service atau database. Schema yang diberikan kepada model membantu model menghasilkan argumen dengan bentuk yang benar, tetapi schema tersebut bukan mekanisme keamanan. Pemanggilan dapat datang dari klien, skrip, model yang salah memahami konteks, atau integrasi lama.

const parsed = createTicketInput.safeParse(rawInput);

if (!parsed.success) {
  return {
    ok: false,
    error: {
      code: "VALIDATION_ERROR",
      message: "Input tool tidak valid.",
      retryable: false,
      details: parsed.error.issues.map((issue) => ({
        path: issue.path.join("."),
        message: issue.message
      }))
    },
    requestId
  };
}

const input = parsed.data;

Hindari menerima any, melakukan type assertion tanpa validasi, atau hanya memeriksa field wajib dengan if sederhana. Validasi juga perlu mencakup batas panjang, enum, format ID, rentang angka, dan aturan lintas-field. Misalnya, tanggal akhir tidak boleh lebih awal daripada tanggal mulai.

Schema input bukan otorisasi

Jangan menaruh userId, role, atau accountId dari argumen model lalu mempercayainya. Identitas tersebut harus berasal dari sesi atau token yang telah diverifikasi. Jika pengguna memilih resource, validasi format ID tetap diperlukan, tetapi kepemilikan resource harus diperiksa di server.

3. Memisahkan autentikasi dan otorisasi

Autentikasi menjawab siapa pemanggilnya. Otorisasi menjawab apakah identitas tersebut boleh melakukan operasi tertentu terhadap resource tertentu. Keduanya harus dijalankan pada setiap pemanggilan tool, termasuk pemanggilan ulang setelah retry.

  • Autentikasi: baca sesi atau bearer token dari request, verifikasi tanda tangan dan masa berlaku, lalu buat konteks pengguna.
  • Otorisasi: periksa permission, tenant, kepemilikan resource, dan aturan bisnis sebelum executor dipanggil.
  • Jangan mengandalkan instruksi system prompt sebagai kontrol akses.
  • Jangan menganggap tool aman hanya karena tool tidak diekspos sebagai endpoint publik.
type AuthContext = {
  userId: string;
  tenantId: string;
  roles: string[];
};

async function requireAuth(request: Request): Promise<AuthContext> {
  const token = request.headers.get("authorization");
  if (!token) throw new AppError("UNAUTHENTICATED", "Login diperlukan.", false);

  const session = await verifySessionToken(token);
  if (!session) {
    throw new AppError("UNAUTHENTICATED", "Sesi tidak valid.", false);
  }

  return session;
}

async function authorizeCreateTicket(ctx: AuthContext) {
  const allowed = ctx.roles.includes("support_agent") || ctx.roles.includes("admin");
  if (!allowed) {
    throw new AppError("FORBIDDEN", "Anda tidak memiliki izin membuat tiket.", false);
  }
}

Pada aplikasi multi-tenant, sertakan tenantId dari konteks terverifikasi pada query database. Jangan menerima tenant dari input tool sebagai sumber kebenaran. Ini mencegah kebocoran data lintas tenant akibat model atau klien mengirim ID organisasi lain.

4. Error terstruktur dan batas Vercel Function

Error yang baik memiliki kode stabil, pesan yang aman untuk pengguna, informasi apakah retry layak dilakukan, dan requestId untuk pelacakan. Jangan mengirim stack trace, query database, token, atau detail provider ke model maupun klien.

type ErrorCode =
  | "VALIDATION_ERROR"
  | "UNAUTHENTICATED"
  | "FORBIDDEN"
  | "NOT_FOUND"
  | "CONFLICT"
  | "RATE_LIMITED"
  | "DEPENDENCY_UNAVAILABLE"
  | "INTERNAL_ERROR";

class AppError extends Error {
  constructor(
    public code: ErrorCode,
    message: string,
    public retryable: boolean,
    public details?: unknown,
    public retryAfterSeconds?: number
  ) {
    super(message);
  }
}

function errorBody(error: AppError, requestId: string) {
  return {
    ok: false,
    error: {
      code: error.code,
      message: error.message,
      retryable: error.retryable,
      ...(error.details ? { details: error.details } : {}),
      ...(error.retryAfterSeconds
        ? { retryAfterSeconds: error.retryAfterSeconds }
        : {})
    },
    requestId
  };
}

Bedakan error permanen dan sementara. VALIDATION_ERROR, UNAUTHENTICATED, FORBIDDEN, dan umumnya NOT_FOUND tidak boleh diulang otomatis. RATE_LIMITED dapat dicoba lagi setelah jeda yang diberikan. Gangguan dependency atau timeout dapat retry jika operasi aman atau memiliki idempotensi.

5. Implementasi endpoint Vercel Function

Contoh berikut memakai handler berbasis Web Request/Response yang umum digunakan pada Vercel Functions. Detail ekspor dapat menyesuaikan framework yang dipakai, tetapi urutan boundary-nya tetap sama: buat request ID, autentikasi, parse body, validasi schema, otorisasi, lalu panggil service.

import { randomUUID } from "node:crypto";

export async function POST(request: Request): Promise<Response> {
  const requestId = request.headers.get("x-request-id") ?? randomUUID();

  try {
    const auth = await requireAuth(request);
    const rawBody: unknown = await request.json();
    const input = createTicketInput.parse(rawBody);

    await authorizeCreateTicket(auth);

    const idempotencyKey = request.headers.get("idempotency-key");
    if (!idempotencyKey) {
      throw new AppError(
        "VALIDATION_ERROR",
        "Header Idempotency-Key diperlukan untuk mutasi.",
        false
      );
    }

    const result = await createTicketIdempotently({
      input,
      auth,
      idempotencyKey,
      requestId
    });

    return Response.json(
      { ok: true, data: result, requestId },
      { status: result.replayed ? 200 : 201, headers: { "x-request-id": requestId } }
    );
  } catch (unknownError) {
    const error = normalizeError(unknownError);
    const status = statusForError(error.code);

    console.error("ticket.create.failed", {
      requestId,
      code: error.code,
      retryable: error.retryable
    });

    return Response.json(errorBody(error, requestId), {
      status,
      headers: {
        "x-request-id": requestId,
        ...(error.retryAfterSeconds
          ? { "retry-after": String(error.retryAfterSeconds) }
          : {})
      }
    });
  }
}

Dalam produksi, x-request-id dari klien sebaiknya divalidasi panjang dan karakternya atau diganti dengan ID internal. Jangan menaruh data pribadi di dalamnya. Request ID harus muncul pada log Function, log service, dan event database yang relevan.

6. Menghubungkan kontrak ke tool Vercel AI SDK

Gunakan schema yang sama untuk tool. Executor tidak boleh melewati policy hanya karena dipanggil dari AI SDK. Jika executor menerima konteks autentikasi secara eksplisit, lebih sulit bagi kode untuk secara tidak sengaja menggunakan identitas global atau identitas dari input model.

import { tool } from "ai";

function createTicketTool(ctx: AuthContext, requestId: string) {
  return tool({
    description: "Membuat tiket dukungan untuk tenant pengguna saat ini.",
    inputSchema: createTicketInput,
    execute: async (input) => {
      await authorizeCreateTicket(ctx);

      const result = await createTicketIdempotently({
        input,
        auth: ctx,
        idempotencyKey: input.clientMutationId,
        requestId
      });

      return ticketOutput.parse({
        ticket: result.ticket,
        requestId,
        replayed: result.replayed
      });
    }
  });
}

Nama properti schema dapat berbeda bergantung pada versi SDK yang digunakan. Prinsipnya tetap: schema harus menjadi sumber kontrak input, output divalidasi sebelum dikembalikan, dan authorization dijalankan di executor atau service yang tidak dapat dilewati.

Untuk operasi berisiko tinggi seperti penghapusan data, perubahan pembayaran, atau pengiriman pesan eksternal, pertimbangkan pola confirmation step. Tool pertama membuat proposal atau preview, sedangkan mutasi final hanya dapat dilakukan setelah konfirmasi eksplisit dari pengguna dan pemeriksaan policy ulang.

7. Idempotensi dan aturan retry

Retry dapat terjadi pada beberapa lapisan: model mengulangi tool call, klien mengulang request setelah timeout, platform atau proxy mengulangi request, atau service melakukan retry terhadap dependency. Timeout tidak membuktikan operasi gagal; database mungkin sudah melakukan commit sebelum koneksi terputus.

Implementasi idempotensi

Simpan kunci idempotensi bersama hash input yang telah dinormalisasi, status proses, dan hasil final. Gunakan unique constraint pada kombinasi tenant dan key. Jika key yang sama datang dengan input berbeda, kembalikan CONFLICT, bukan menjalankan operasi kedua.

type IdempotentCommand = {
  input: CreateTicketInput;
  auth: AuthContext;
  idempotencyKey: string;
  requestId: string;
};

async function createTicketIdempotently(command: IdempotentCommand) {
  const fingerprint = hashCanonicalJson({
    tenantId: command.auth.tenantId,
    input: command.input
  });

  const existing = await idempotencyStore.find(
    command.auth.tenantId,
    command.idempotencyKey
  );

  if (existing) {
    if (existing.fingerprint !== fingerprint) {
      throw new AppError(
        "CONFLICT",
        "Idempotency key sudah digunakan untuk input berbeda.",
        false
      );
    }
    if (existing.status === "completed") return { ...existing.result, replayed: true };
    if (existing.status === "processing") {
      throw new AppError("CONFLICT", "Operasi masih diproses.", true);
    }
  }

  await idempotencyStore.reserve({
    tenantId: command.auth.tenantId,
    key: command.idempotencyKey,
    fingerprint,
    requestId: command.requestId
  });

  try {
    const ticket = await ticketService.create({
      tenantId: command.auth.tenantId,
      createdBy: command.auth.userId,
      title: command.input.title,
      description: command.input.description,
      priority: command.input.priority
    });

    const result = { ticket, replayed: false };
    await idempotencyStore.complete(command.auth.tenantId, command.idempotencyKey, result);
    return result;
  } catch (error) {
    await idempotencyStore.fail(command.auth.tenantId, command.idempotencyKey);
    throw error;
  }
}

Reservasi dan mutasi idealnya memakai transaksi atau mekanisme atomic yang sesuai storage. Jika tool mengirim email, memanggil payment provider, atau menulis ke sistem lain, idempotensi juga harus diteruskan ke dependency tersebut bila didukung. Untuk workflow multi-langkah, gunakan outbox atau workflow durable; jangan menganggap satu transaksi database dapat membungkus layanan eksternal.

Aturan retry yang aman

  • Retry hanya untuk error yang ditandai retryable: true.
  • Gunakan exponential backoff dengan jitter dan batas jumlah percobaan.
  • Jangan retry error validasi, autentikasi, otorisasi, atau konflik input.
  • Mutasi hanya boleh diulang jika idempotency key tetap sama dan kontrak menjamin hasil yang sama.
  • Untuk operasi non-idempoten tanpa dukungan deduplikasi, lebih aman mengembalikan status tidak pasti daripada menjalankan ulang secara membabi buta.
  • Batasi total waktu retry agar tidak melebihi timeout Function dan timeout dependency.

Aturan praktis: retry adalah kebijakan pemanggil, sedangkan idempotensi adalah jaminan penerima. Keduanya harus dirancang bersama.

8. Skenario kegagalan yang perlu dirancang

SkenarioRespons yang disarankan
Body JSON rusakVALIDATION_ERROR, tidak retry.
Schema valid tetapi role tidak cukupFORBIDDEN, jangan membocorkan detail policy.
Token kedaluwarsa saat tool berjalanUNAUTHENTICATED; aplikasi meminta login ulang.
Request timeout setelah database commitKlien mengulang dengan key sama dan menerima hasil tersimpan.
Dependency rate limitRATE_LIMITED dengan retryAfterSeconds.
Idempotency key sama, payload berbedaCONFLICT; jangan mengeksekusi mutasi.
Output service berubah dan tidak sesuai kontrakCatat error internal, kembalikan INTERNAL_ERROR, dan perbaiki adapter.

9. Pengujian kontrak dan integrasi

Pengujian kontrak harus memeriksa bukan hanya happy path, tetapi juga perilaku keamanan dan pengulangan. Schema test memastikan bentuk data, sementara integration test memastikan boundary benar-benar memanggil auth, policy, idempotency store, dan service.

import { describe, expect, it } from "vitest";

 describe("create ticket contract", () => {
  it("menolak priority yang tidak dikenal", () => {
    const result = createTicketInput.safeParse({
      title: "Printer rusak",
      description: "Tidak dapat mencetak",
      priority: "urgent",
      clientMutationId: "00000000-0000-4000-8000-000000000001"
    });

    expect(result.success).toBe(false);
  });

  it("mengembalikan hasil yang sama untuk idempotency key yang sama", async () => {
    const first = await invokeCreate({ key: "key-1", title: "A" });
    const second = await invokeCreate({ key: "key-1", title: "A" });

    expect(second.ticket.id).toBe(first.ticket.id);
    expect(second.replayed).toBe(true);
    expect(ticketRepository.create).toHaveBeenCalledTimes(1);
  });

  it("menolak key yang sama untuk payload berbeda", async () => {
    await invokeCreate({ key: "key-2", title: "A" });

    await expect(invokeCreate({ key: "key-2", title: "B" }))
      .rejects.toMatchObject({ code: "CONFLICT" });
  });
});

Tambahkan pengujian untuk tenant isolation, pengguna tanpa role, token invalid, timeout setelah commit, response dependency yang malformed, dan concurrency dua request dengan key yang sama. Pengujian concurrency penting karena pemeriksaan lalu insert yang tidak atomic dapat menghasilkan duplikasi.

10. Observability dan debugging

Log terstruktur membuat kegagalan tool dapat ditelusuri tanpa mencatat isi percakapan atau data sensitif. Minimal, catat:

  • requestId dan idempotency key yang sudah di-hash atau dipotong sesuai kebijakan privasi.
  • Nama tool dan versi kontrak.
  • Tenant atau user identifier internal yang tidak sensitif.
  • Durasi validasi, authorization, database, dan dependency eksternal.
  • Kode error, status retryable, serta jumlah percobaan.
  • Apakah hasil merupakan replay idempotensi.

Jangan mencatat access token, prompt lengkap, deskripsi tiket tanpa redaksi, atau input pengguna yang dapat mengandung rahasia. Gunakan metrik untuk error berdasarkan kode, latency berdasarkan operasi, tingkat replay, konflik idempotensi, dan penolakan authorization. Trace terdistribusi akan membantu menghubungkan Vercel Function, executor tool, database, dan dependency eksternal melalui request ID atau trace context.

11. Checklist sebelum kontrak tool dirilis

  1. Input dan output memiliki schema eksplisit dan diuji.
  2. Identitas pengguna tidak berasal dari argumen model.
  3. Authorization dijalankan setiap kali executor dipanggil.
  4. Query selalu dibatasi oleh tenant atau resource scope dari konteks terverifikasi.
  5. Error memiliki kode stabil, pesan aman, dan flag retryable.
  6. Mutasi memiliki idempotency key, fingerprint payload, dan unique constraint.
  7. Retry memakai backoff, jitter, batas percobaan, dan hanya untuk error yang sesuai.
  8. Request ID diteruskan ke log dan dependency.
  9. Test mencakup replay, konflik key, concurrency, timeout, dan akses lintas tenant.
  10. Kontrak memiliki strategi evolusi, misalnya field opsional atau versi tool baru.

Dengan struktur ini, tool Vercel AI SDK menjadi boundary yang dapat diperlakukan seperti API produksi biasa. Model boleh menentukan kapan tool relevan dan mengisi argumen, tetapi server tetap memegang kendali atas validasi, identitas, otorisasi, deduplikasi, error, dan konsistensi mutasi.