@dingdawg/sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * DurableDingDawgClient — unit tests for DDAG v1 TypeScript SDK
3
+ */
4
+
5
+ import { jest, describe, it, expect, beforeEach } from "@jest/globals";
6
+ import { DurableDingDawgClient, AgentFSMState } from "../durable.js";
7
+ import { DingDawgApiError } from "../client.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mock helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function makeFetch(body: unknown, ok = true, status = 200) {
14
+ return jest.fn(async () => ({
15
+ ok,
16
+ status,
17
+ headers: { get: () => "application/json" },
18
+ json: async () => body,
19
+ })) as unknown as typeof fetch;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Fixtures
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const DURABLE_RESPONSE = {
27
+ reply: "Compliance report generated.",
28
+ session_id: "sess-ddag-001",
29
+ timestamp: "2026-04-05T12:00:00Z",
30
+ checkpoint_cid: "ipfs:QmTestCID123",
31
+ step_index: 3,
32
+ verified: true,
33
+ fsm_state: "done",
34
+ proof_cid: "ipfs:QmProof456",
35
+ };
36
+
37
+ const SOUL_RESPONSE = {
38
+ soul_id: "soul-001",
39
+ agent_id: "acme-support",
40
+ soul_cid: "ipfs:QmSoul789",
41
+ mission: "Govern AI reliably.",
42
+ learned_prefs: { preferred_model: "gpt-5.4" },
43
+ created_at: "2026-04-01T00:00:00Z",
44
+ updated_at: "2026-04-05T12:00:00Z",
45
+ };
46
+
47
+ const CHECKPOINT_RESPONSE = {
48
+ session_id: "sess-ddag-001",
49
+ step_index: 3,
50
+ state_cid: "ipfs:QmCheckpoint321",
51
+ fsm_state: "checkpointed",
52
+ created_at: "2026-04-05T12:00:00Z",
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Tests
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe("DurableDingDawgClient — construction", () => {
60
+ it("extends DingDawgClient — all namespaces present", () => {
61
+ const client = new DurableDingDawgClient({ apiKey: "dd_test_key" });
62
+ expect(typeof client.agent.sendMessage).toBe("function");
63
+ expect(typeof client.billing.currentMonth).toBe("function");
64
+ expect(typeof client.invokeWithCheckpoint).toBe("function");
65
+ expect(typeof client.resume).toBe("function");
66
+ expect(typeof client.getSoul).toBe("function");
67
+ expect(typeof client.getCheckpoint).toBe("function");
68
+ });
69
+
70
+ it("throws TypeError on empty apiKey", () => {
71
+ expect(() => new DurableDingDawgClient({ apiKey: "" })).toThrow(TypeError);
72
+ });
73
+ });
74
+
75
+ describe("DurableDingDawgClient — invokeWithCheckpoint", () => {
76
+ it("returns DurableResponse with checkpoint_cid and fsm_state", async () => {
77
+ global.fetch = makeFetch(DURABLE_RESPONSE);
78
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
79
+ const result = await client.invokeWithCheckpoint("acme-support", {
80
+ message: "Run compliance report",
81
+ userId: "user_123",
82
+ });
83
+ expect(result.reply).toBe("Compliance report generated.");
84
+ expect(result.checkpoint_cid).toBe("ipfs:QmTestCID123");
85
+ expect(result.step_index).toBe(3);
86
+ expect(result.verified).toBe(true);
87
+ expect(result.fsm_state).toBe(AgentFSMState.Done);
88
+ expect(result.proof_cid).toBe("ipfs:QmProof456");
89
+ });
90
+
91
+ it("sends to correct durable invoke endpoint", async () => {
92
+ const mock = makeFetch(DURABLE_RESPONSE);
93
+ global.fetch = mock;
94
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
95
+ await client.invokeWithCheckpoint("acme-support", { message: "Hello" });
96
+ const [url] = (mock as jest.Mock).mock.calls[0] as [string, RequestInit];
97
+ expect(url).toContain("/api/v2/agents/acme-support/durable/invoke");
98
+ });
99
+
100
+ it("sends Idempotency-Key header when idempotency_key is set", async () => {
101
+ const mock = makeFetch(DURABLE_RESPONSE);
102
+ global.fetch = mock;
103
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
104
+ await client.invokeWithCheckpoint("acme-support", {
105
+ message: "Hello",
106
+ idempotency_key: "my-idem-key-001",
107
+ });
108
+ const [, init] = (mock as jest.Mock).mock.calls[0] as [string, RequestInit];
109
+ const headers = init.headers as Record<string, string>;
110
+ expect(headers["Idempotency-Key"]).toBe("my-idem-key-001");
111
+ });
112
+
113
+ it("includes resume_cid in payload when provided", async () => {
114
+ const mock = makeFetch(DURABLE_RESPONSE);
115
+ global.fetch = mock;
116
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
117
+ await client.invokeWithCheckpoint("acme-support", {
118
+ message: "Continue task",
119
+ resume_cid: "ipfs:QmPriorCID",
120
+ });
121
+ const [, init] = (mock as jest.Mock).mock.calls[0] as [string, RequestInit];
122
+ const body = JSON.parse(init.body as string);
123
+ expect(body.resume_cid).toBe("ipfs:QmPriorCID");
124
+ });
125
+
126
+ it("throws DingDawgApiError on 500 response", async () => {
127
+ global.fetch = makeFetch({ detail: "server error" }, false, 500);
128
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
129
+ await expect(
130
+ client.invokeWithCheckpoint("acme-support", { message: "fail" })
131
+ ).rejects.toBeInstanceOf(DingDawgApiError);
132
+ });
133
+ });
134
+
135
+ describe("DurableDingDawgClient — resume", () => {
136
+ it("calls resume endpoint with checkpoint_cid in body", async () => {
137
+ const mock = makeFetch({ ...DURABLE_RESPONSE, fsm_state: "running" });
138
+ global.fetch = mock;
139
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
140
+ const result = await client.resume("acme-support", "ipfs:QmPriorCID");
141
+ const [url, init] = (mock as jest.Mock).mock.calls[0] as [string, RequestInit];
142
+ expect(url).toContain("/api/v2/agents/acme-support/durable/resume");
143
+ const body = JSON.parse(init.body as string);
144
+ expect(body.checkpoint_cid).toBe("ipfs:QmPriorCID");
145
+ expect(result.fsm_state).toBe(AgentFSMState.Running);
146
+ });
147
+ });
148
+
149
+ describe("DurableDingDawgClient — getSoul", () => {
150
+ it("returns AgentSoul with all fields", async () => {
151
+ global.fetch = makeFetch(SOUL_RESPONSE);
152
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
153
+ const soul = await client.getSoul("acme-support");
154
+ expect(soul.soul_id).toBe("soul-001");
155
+ expect(soul.soul_cid).toBe("ipfs:QmSoul789");
156
+ expect(soul.mission).toBe("Govern AI reliably.");
157
+ expect(soul.learned_prefs["preferred_model"]).toBe("gpt-5.4");
158
+ });
159
+
160
+ it("calls correct soul endpoint", async () => {
161
+ const mock = makeFetch(SOUL_RESPONSE);
162
+ global.fetch = mock;
163
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
164
+ await client.getSoul("acme-support");
165
+ const [url] = (mock as jest.Mock).mock.calls[0] as [string, RequestInit];
166
+ expect(url).toContain("/api/v2/agents/acme-support/soul");
167
+ });
168
+ });
169
+
170
+ describe("DurableDingDawgClient — getCheckpoint", () => {
171
+ it("returns CheckpointState when found", async () => {
172
+ global.fetch = makeFetch(CHECKPOINT_RESPONSE);
173
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
174
+ const ckpt = await client.getCheckpoint("acme-support", "sess-ddag-001");
175
+ expect(ckpt).not.toBeNull();
176
+ expect(ckpt!.state_cid).toBe("ipfs:QmCheckpoint321");
177
+ expect(ckpt!.fsm_state).toBe(AgentFSMState.Checkpointed);
178
+ expect(ckpt!.step_index).toBe(3);
179
+ });
180
+
181
+ it("returns null on 404", async () => {
182
+ global.fetch = makeFetch({ detail: "Not found" }, false, 404);
183
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
184
+ const ckpt = await client.getCheckpoint("acme-support", "unknown-session");
185
+ expect(ckpt).toBeNull();
186
+ });
187
+
188
+ it("rethrows non-404 errors", async () => {
189
+ global.fetch = makeFetch({ detail: "server error" }, false, 500);
190
+ const client = new DurableDingDawgClient({ apiKey: "dd_test" });
191
+ await expect(
192
+ client.getCheckpoint("acme-support", "sess-001")
193
+ ).rejects.toBeInstanceOf(DingDawgApiError);
194
+ });
195
+ });
196
+
197
+ describe("AgentFSMState enum", () => {
198
+ it("has all 10 states", () => {
199
+ const states = Object.values(AgentFSMState);
200
+ expect(states).toContain("idle");
201
+ expect(states).toContain("running");
202
+ expect(states).toContain("tool_pending");
203
+ expect(states).toContain("verifying");
204
+ expect(states).toContain("committing");
205
+ expect(states).toContain("remediating");
206
+ expect(states).toContain("checkpointed");
207
+ expect(states).toContain("resuming");
208
+ expect(states).toContain("done");
209
+ expect(states).toContain("failed");
210
+ expect(states).toHaveLength(10);
211
+ });
212
+ });
package/src/client.ts ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * @dingdawg/sdk — DingDawgClient
3
+ *
4
+ * The primary entry point for B2B2C partners embedding DingDawg agents.
5
+ *
6
+ * Design goals:
7
+ * - Zero runtime dependencies (Node 18+ built-in fetch only)
8
+ * - Full TypeScript types on all inputs and outputs
9
+ * - Typed error class so callers can catch + inspect failures
10
+ * - Grouped API namespaces: client.agent.* and client.billing.*
11
+ */
12
+
13
+ import type {
14
+ AgentRecord,
15
+ BillingSummary,
16
+ CreateAgentOptions,
17
+ DingDawgClientOptions,
18
+ MonthlyBillingSummary,
19
+ PaginatedList,
20
+ SendMessageOptions,
21
+ TriggerResponse,
22
+ ApiErrorBody,
23
+ DingDawgApiErrorDetails,
24
+ } from "./types.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Default configuration
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const DEFAULT_BASE_URL = "https://api.dingdawg.com";
31
+
32
+ const SDK_USER_AGENT = "@dingdawg/sdk/0.1.0";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Typed error class
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Thrown by all DingDawgClient methods when the API returns a non-2xx status.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * try {
44
+ * await client.agent.get("non-existent-id");
45
+ * } catch (err) {
46
+ * if (err instanceof DingDawgApiError) {
47
+ * console.error(err.status, err.body?.detail);
48
+ * }
49
+ * }
50
+ * ```
51
+ */
52
+ export class DingDawgApiError extends Error {
53
+ /** HTTP status code. */
54
+ readonly status: number;
55
+ /** Parsed error body from the API, or null if the body was not JSON. */
56
+ readonly body: ApiErrorBody | null;
57
+
58
+ constructor(details: DingDawgApiErrorDetails, message?: string) {
59
+ const derived =
60
+ message ??
61
+ details.body?.detail ??
62
+ details.body?.message ??
63
+ `DingDawg API error: HTTP ${details.status}`;
64
+ super(String(derived));
65
+ this.name = "DingDawgApiError";
66
+ this.status = details.status;
67
+ this.body = details.body;
68
+ // Maintains proper prototype chain in transpiled environments
69
+ Object.setPrototypeOf(this, DingDawgApiError.prototype);
70
+ }
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Internal HTTP helpers
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Parse a Response, throwing DingDawgApiError on non-2xx status.
79
+ *
80
+ * @internal
81
+ */
82
+ async function _parseResponse<T>(resp: Response): Promise<T> {
83
+ let body: unknown = null;
84
+ const contentType = resp.headers.get("content-type") ?? "";
85
+
86
+ if (contentType.includes("application/json")) {
87
+ try {
88
+ body = await resp.json();
89
+ } catch {
90
+ body = null;
91
+ }
92
+ } else {
93
+ try {
94
+ body = await resp.text();
95
+ } catch {
96
+ body = null;
97
+ }
98
+ }
99
+
100
+ if (!resp.ok) {
101
+ const errorBody: ApiErrorBody | null =
102
+ body !== null && typeof body === "object"
103
+ ? (body as ApiErrorBody)
104
+ : typeof body === "string"
105
+ ? { detail: body }
106
+ : null;
107
+
108
+ throw new DingDawgApiError({ status: resp.status, body: errorBody });
109
+ }
110
+
111
+ return body as T;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Agent API namespace
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Methods for managing agents. Available as `client.agent`. */
119
+ export interface AgentApi {
120
+ /**
121
+ * Create a new agent.
122
+ *
123
+ * @param opts - Agent creation options.
124
+ * @returns The newly created AgentRecord.
125
+ */
126
+ create(opts: CreateAgentOptions): Promise<AgentRecord>;
127
+
128
+ /**
129
+ * List all agents belonging to the authenticated partner account.
130
+ *
131
+ * @param params - Optional pagination parameters.
132
+ * @returns Paginated list of AgentRecords.
133
+ */
134
+ list(params?: { limit?: number; offset?: number }): Promise<PaginatedList<AgentRecord>>;
135
+
136
+ /**
137
+ * Get a single agent by ID.
138
+ *
139
+ * @param id - Agent UUID.
140
+ * @returns The AgentRecord.
141
+ */
142
+ get(id: string): Promise<AgentRecord>;
143
+
144
+ /**
145
+ * Send a message to an agent via the trigger endpoint.
146
+ *
147
+ * @param agentId - Agent UUID.
148
+ * @param message - Message text or full options object.
149
+ * @returns The agent's reply and session information.
150
+ */
151
+ sendMessage(
152
+ agentId: string,
153
+ message: string | SendMessageOptions
154
+ ): Promise<TriggerResponse>;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Billing API namespace
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Methods for querying billing and usage. Available as `client.billing`. */
162
+ export interface BillingApi {
163
+ /**
164
+ * Get billing details for the current calendar month.
165
+ *
166
+ * @returns Monthly billing summary including line items and free-tier usage.
167
+ */
168
+ currentMonth(): Promise<MonthlyBillingSummary>;
169
+
170
+ /**
171
+ * Get aggregate billing summary (lifetime + current month).
172
+ *
173
+ * @returns Full billing summary.
174
+ */
175
+ summary(): Promise<BillingSummary>;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Main client class
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * DingDawgClient — the single entry point for the DingDawg Agent SDK.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * import { DingDawgClient } from "@dingdawg/sdk";
188
+ *
189
+ * const client = new DingDawgClient({ apiKey: "dd_live_..." });
190
+ *
191
+ * const agent = await client.agent.create({
192
+ * name: "Support Bot",
193
+ * handle: "acme-support",
194
+ * agentType: "business",
195
+ * });
196
+ *
197
+ * const reply = await client.agent.sendMessage(agent.id, "Hello!");
198
+ * console.log(reply.reply);
199
+ * ```
200
+ */
201
+ export class DingDawgClient {
202
+ private readonly _apiKey: string;
203
+ private readonly _baseUrl: string;
204
+
205
+ /** Methods for managing agents. */
206
+ readonly agent: AgentApi;
207
+
208
+ /** Methods for querying billing and usage. */
209
+ readonly billing: BillingApi;
210
+
211
+ /**
212
+ * Create a new DingDawgClient instance.
213
+ *
214
+ * @param opts - Configuration options.
215
+ * @throws {TypeError} When apiKey is missing or empty.
216
+ */
217
+ constructor(opts: DingDawgClientOptions) {
218
+ if (!opts.apiKey || typeof opts.apiKey !== "string" || opts.apiKey.trim() === "") {
219
+ throw new TypeError(
220
+ "DingDawgClient: apiKey is required and must be a non-empty string"
221
+ );
222
+ }
223
+
224
+ this._apiKey = opts.apiKey.trim();
225
+ this._baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
226
+
227
+ // Bind namespace objects so methods can be destructured safely
228
+ this.agent = this._buildAgentApi();
229
+ this.billing = this._buildBillingApi();
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // Internal HTTP request
234
+ // -------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Perform an authenticated JSON request.
238
+ *
239
+ * @internal
240
+ */
241
+ private async _request<T>(
242
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
243
+ path: string,
244
+ body?: unknown
245
+ ): Promise<T> {
246
+ const url = `${this._baseUrl}${path}`;
247
+
248
+ const headers: Record<string, string> = {
249
+ "Content-Type": "application/json",
250
+ "User-Agent": SDK_USER_AGENT,
251
+ Authorization: `Bearer ${this._apiKey}`,
252
+ "X-DingDawg-SDK": "1",
253
+ };
254
+
255
+ const init: RequestInit = { method, headers };
256
+
257
+ if (body !== undefined && method !== "GET") {
258
+ init.body = JSON.stringify(body);
259
+ }
260
+
261
+ let resp: Response;
262
+ try {
263
+ resp = await fetch(url, init);
264
+ } catch (err: unknown) {
265
+ // Network-level failure (DNS, connection refused, etc.)
266
+ const message = err instanceof Error ? err.message : String(err);
267
+ throw new DingDawgApiError(
268
+ { status: 0, body: { detail: `Network error: ${message}` } },
269
+ `Network error reaching DingDawg API: ${message}`
270
+ );
271
+ }
272
+
273
+ return _parseResponse<T>(resp);
274
+ }
275
+
276
+ // -------------------------------------------------------------------------
277
+ // Agent API builder
278
+ // -------------------------------------------------------------------------
279
+
280
+ private _buildAgentApi(): AgentApi {
281
+ return {
282
+ create: async (opts: CreateAgentOptions): Promise<AgentRecord> => {
283
+ const payload = {
284
+ name: opts.name,
285
+ handle: opts.handle,
286
+ agent_type: opts.agentType ?? "business",
287
+ industry_type: opts.industry,
288
+ system_prompt: opts.systemPrompt,
289
+ template_id: opts.templateId,
290
+ branding: opts.branding
291
+ ? {
292
+ primary_color: opts.branding.primaryColor,
293
+ avatar_url: opts.branding.avatarUrl,
294
+ }
295
+ : undefined,
296
+ };
297
+
298
+ const raw = await this._request<Record<string, unknown>>(
299
+ "POST",
300
+ "/api/v2/partner/agents",
301
+ payload
302
+ );
303
+
304
+ return _normalizeAgent(raw);
305
+ },
306
+
307
+ list: async (
308
+ params: { limit?: number; offset?: number } = {}
309
+ ): Promise<PaginatedList<AgentRecord>> => {
310
+ const qs = new URLSearchParams();
311
+ if (params.limit !== undefined) qs.set("limit", String(params.limit));
312
+ if (params.offset !== undefined) qs.set("offset", String(params.offset));
313
+ const queryString = qs.toString();
314
+ const path = queryString
315
+ ? `/api/v2/partner/agents?${queryString}`
316
+ : "/api/v2/partner/agents";
317
+
318
+ const raw = await this._request<Record<string, unknown>>("GET", path);
319
+ const items = (raw["items"] ?? raw["agents"] ?? []) as Record<
320
+ string,
321
+ unknown
322
+ >[];
323
+
324
+ return {
325
+ items: items.map(_normalizeAgent),
326
+ total: (raw["total"] as number) ?? items.length,
327
+ limit: (raw["limit"] as number) ?? (params.limit ?? 20),
328
+ offset: (raw["offset"] as number) ?? (params.offset ?? 0),
329
+ };
330
+ },
331
+
332
+ get: async (id: string): Promise<AgentRecord> => {
333
+ const raw = await this._request<Record<string, unknown>>(
334
+ "GET",
335
+ `/api/v2/partner/agents/${encodeURIComponent(id)}`
336
+ );
337
+ return _normalizeAgent(raw);
338
+ },
339
+
340
+ sendMessage: async (
341
+ agentId: string,
342
+ message: string | SendMessageOptions
343
+ ): Promise<TriggerResponse> => {
344
+ const opts: SendMessageOptions =
345
+ typeof message === "string" ? { message } : message;
346
+
347
+ const payload = {
348
+ message: opts.message,
349
+ user_id: opts.userId,
350
+ session_id: opts.sessionId,
351
+ metadata: opts.metadata,
352
+ };
353
+
354
+ const raw = await this._request<Record<string, unknown>>(
355
+ "POST",
356
+ `/api/v1/agents/${encodeURIComponent(agentId)}/trigger`,
357
+ payload
358
+ );
359
+
360
+ const triggerResult: TriggerResponse = {
361
+ reply: String(raw["reply"] ?? raw["response"] ?? ""),
362
+ sessionId: String(raw["session_id"] ?? raw["sessionId"] ?? ""),
363
+ timestamp: String(raw["timestamp"] ?? new Date().toISOString()),
364
+ queued: raw["queued"] === true,
365
+ ...(raw["model"] !== undefined ? { model: String(raw["model"]) } : {}),
366
+ };
367
+ return triggerResult;
368
+ },
369
+ };
370
+ }
371
+
372
+ // -------------------------------------------------------------------------
373
+ // Billing API builder
374
+ // -------------------------------------------------------------------------
375
+
376
+ private _buildBillingApi(): BillingApi {
377
+ return {
378
+ currentMonth: async (): Promise<MonthlyBillingSummary> => {
379
+ const raw = await this._request<Record<string, unknown>>(
380
+ "GET",
381
+ "/api/v2/partner/billing/current-month"
382
+ );
383
+ return _normalizeMonthlySummary(raw);
384
+ },
385
+
386
+ summary: async (): Promise<BillingSummary> => {
387
+ const raw = await this._request<Record<string, unknown>>(
388
+ "GET",
389
+ "/api/v2/partner/billing"
390
+ );
391
+
392
+ const currentMonth = _normalizeMonthlySummary(
393
+ (raw["current_month"] as Record<string, unknown> | undefined) ?? {}
394
+ );
395
+
396
+ const billingSummaryResult: BillingSummary = {
397
+ totalActions: (raw["total_actions"] as number) ?? 0,
398
+ totalCents: (raw["total_cents"] as number) ?? 0,
399
+ currentMonth,
400
+ ...(raw["stripe_customer_id"] !== undefined
401
+ ? { stripeCustomerId: String(raw["stripe_customer_id"]) }
402
+ : {}),
403
+ };
404
+ return billingSummaryResult;
405
+ },
406
+ };
407
+ }
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Normalizer helpers (snake_case → camelCase)
412
+ // ---------------------------------------------------------------------------
413
+
414
+ function _normalizeAgent(raw: Record<string, unknown>): AgentRecord {
415
+ const branding = raw["branding"] as Record<string, unknown> | undefined;
416
+
417
+ return {
418
+ id: String(raw["id"] ?? raw["agent_id"] ?? ""),
419
+ handle: String(raw["handle"] ?? ""),
420
+ name: String(raw["name"] ?? ""),
421
+ agentType: (raw["agent_type"] ?? raw["agentType"] ?? "business") as AgentRecord["agentType"],
422
+ industry: raw["industry_type"] !== undefined
423
+ ? String(raw["industry_type"])
424
+ : raw["industry"] !== undefined
425
+ ? String(raw["industry"])
426
+ : null,
427
+ status: (raw["status"] ?? "active") as AgentRecord["status"],
428
+ createdAt: String(raw["created_at"] ?? raw["createdAt"] ?? ""),
429
+ updatedAt: String(raw["updated_at"] ?? raw["updatedAt"] ?? ""),
430
+ ...(branding
431
+ ? {
432
+ branding: {
433
+ ...(branding["primary_color"] !== undefined
434
+ ? { primaryColor: String(branding["primary_color"]) }
435
+ : {}),
436
+ ...(branding["avatar_url"] !== undefined
437
+ ? { avatarUrl: String(branding["avatar_url"]) }
438
+ : {}),
439
+ },
440
+ }
441
+ : {}),
442
+ };
443
+ }
444
+
445
+ function _normalizeMonthlySummary(
446
+ raw: Record<string, unknown>
447
+ ): MonthlyBillingSummary {
448
+ const lineItemsRaw = Array.isArray(raw["line_items"]) ? raw["line_items"] : [];
449
+
450
+ return {
451
+ month: String(raw["month"] ?? ""),
452
+ totalActions: (raw["total_actions"] as number) ?? 0,
453
+ totalCents: (raw["total_cents"] as number) ?? 0,
454
+ freeActionsRemaining: (raw["free_actions_remaining"] as number) ?? 0,
455
+ lineItems: (lineItemsRaw as Record<string, unknown>[]).map((item) => ({
456
+ action: String(item["action"] ?? ""),
457
+ count: (item["count"] as number) ?? 0,
458
+ costCents: (item["cost_cents"] as number) ?? 0,
459
+ })),
460
+ };
461
+ }