@wtfalch/ai 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -45,6 +45,13 @@ settles in time, HTTP 202 with the run's current state (usually still
45
45
  keeps going after the deadline even if the caller did not wait for it.
46
46
  Without `wait`, `submit` returns as soon as the run is queued, as before.
47
47
 
48
+ `submitBatch(runs)` queues up to 100 runs in one request (`POST /v1/runs/batch`).
49
+ Each item is an ordinary queued submit with its own `requestId`, rate-limit slot
50
+ and budget reservation. The result array is in request order, and each item is
51
+ either `{ run }` or `{ error: { code, message } }`. A refused item does not affect
52
+ the others, and resending the same batch replays the runs already created. Batch
53
+ items cannot stream or `wait`, and the whole request body is capped at 256 KB.
54
+
48
55
  A fal image offering (seedream-v4, seedream-v4-edit, birefnet) settles the same way and
49
56
  returns its files in `output.files`:
50
57
 
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ClientOptions } from './transport.js';
2
- import type { BalanceReadingView, BudgetView, ConnectionView, CreateConnection, CreateOffering, OfferingAccessView, OfferingAdminView, OfferingView, RunRequest, RunStreamEvent, RunView, SetOfferingAccess, SetWebhookAddress, UpdateBudget, UpdateOffering, UsageCostView, UsageView, WebhookDeliveryView, WebhookView } from './types.js';
2
+ import type { BalanceReadingView, BatchRunResult, BudgetView, ConnectionView, ConsumptionRecordView, CreateConnection, CreateOffering, OfferingAccessView, OfferingAdminView, OfferingView, RunRequest, RunStreamEvent, RunView, SetOfferingAccess, SetWebhookAddress, SetWebhookResult, UpdateBudget, UpdateOffering, UsageCostView, UsageView, WebhookDeliveryView, WebhookProvisioning, WebhookView } from './types.js';
3
3
  export { ApiError, type ClientOptions } from './transport.js';
4
4
  export declare function createAiClient(options: ClientOptions): {
5
5
  createConnection: (input: CreateConnection) => Promise<ConnectionView>;
@@ -16,6 +16,11 @@ export declare function createAiClient(options: ClientOptions): {
16
16
  submit: (input: RunRequest, options?: {
17
17
  wait?: number;
18
18
  }) => Promise<RunView>;
19
+ /** Submits up to 100 runs in one request. Each is an ordinary queued submit with
20
+ * its own `requestId`, so resending a batch replays the runs already created.
21
+ * Results are in request order; a refused item carries its error instead of a run.
22
+ * Streaming and `wait` are not available here. */
23
+ submitBatch: (runs: RunRequest[]) => Promise<BatchRunResult[]>;
19
24
  /** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
20
25
  * a `delta` per piece of text the provider streamed, then exactly one `done`
21
26
  * carrying the run's final view, however the stream ended. Stop iterating
@@ -47,6 +52,14 @@ export declare function createAiClient(options: ClientOptions): {
47
52
  after?: string;
48
53
  limit?: number;
49
54
  }) => Promise<UsageView[]>;
55
+ listConsumption: (page?: {
56
+ after?: string;
57
+ limit?: number;
58
+ }) => Promise<ConsumptionRecordView[]>;
59
+ exportConsumption: (range: {
60
+ from: string;
61
+ to: string;
62
+ }) => Promise<string>;
50
63
  listCostUsage: (page?: {
51
64
  after?: string;
52
65
  limit?: number;
@@ -59,8 +72,8 @@ export declare function createAiClient(options: ClientOptions): {
59
72
  limit?: number;
60
73
  }) => Promise<BalanceReadingView[]>;
61
74
  getWebhook: () => Promise<WebhookView>;
62
- setWebhookAddress: (input: SetWebhookAddress) => Promise<WebhookView>;
63
- rotateWebhookSecret: (revision: number) => Promise<WebhookView>;
75
+ setWebhookAddress: (input: SetWebhookAddress) => Promise<SetWebhookResult>;
76
+ rotateWebhookSecret: (revision: number) => Promise<WebhookProvisioning>;
64
77
  listWebhookDeliveries: (page?: {
65
78
  after?: string;
66
79
  limit?: number;
package/dist/client.js CHANGED
@@ -20,6 +20,11 @@ export function createAiClient(options) {
20
20
  : `/v1/runs?${new URLSearchParams({ wait: String(options.wait) })}`, input, 'json',
21
21
  // The server may hold the request for the whole wait.
22
22
  30000 + (options.wait ?? 0) * 1000),
23
+ /** Submits up to 100 runs in one request. Each is an ordinary queued submit with
24
+ * its own `requestId`, so resending a batch replays the runs already created.
25
+ * Results are in request order; a refused item carries its error instead of a run.
26
+ * Streaming and `wait` are not available here. */
27
+ submitBatch: (runs) => call('POST', '/v1/runs/batch', { runs }),
23
28
  /** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
24
29
  * a `delta` per piece of text the provider streamed, then exactly one `done`
25
30
  * carrying the run's final view, however the stream ended. Stop iterating
@@ -39,6 +44,8 @@ export function createAiClient(options) {
39
44
  listBudgets: (page = {}) => call('GET', `/v1/budgets?${pageQuery(page)}`),
40
45
  updateBudget: (input) => call('PUT', '/v1/budgets', input),
41
46
  listUsage: (page = {}) => call('GET', `/v1/usage?${pageQuery(page)}`),
47
+ listConsumption: (page = {}) => call('GET', `/v1/consumption?${pageQuery(page)}`),
48
+ exportConsumption: (range) => call('GET', `/v1/consumption/export?${new URLSearchParams(range)}`, undefined, 'text'),
42
49
  listCostUsage: (page = {}) => call('GET', `/v1/usage/cost?${pageQuery(page)}`),
43
50
  exportUsage: (range) => call('GET', `/v1/usage/export?${new URLSearchParams(range)}`, undefined, 'text'),
44
51
  listBalances: (page = {}) => call('GET', `/v1/balances?${pageQuery(page)}`),
@@ -0,0 +1,21 @@
1
+ /** Local, network-free preview of spend before a run is submitted. Neither function is
2
+ * authoritative: the service still bounds real work by the model's own configured
3
+ * `maximumInputTokens`, and the real charge always comes from the provider's reported
4
+ * usage, settled through the service's own `costMicros`. These exist so a caller can
5
+ * preview spend before submitting, not to replace that settlement.
6
+ */
7
+ /** A fast, provider-agnostic approximation - roughly 4 characters per token for English
8
+ * text, the same rule of thumb OpenAI and Anthropic publish for a rough estimate. It is
9
+ * not any provider's real tokenizer and can be off by a wide margin for other scripts,
10
+ * code, or unusual token boundaries. */
11
+ export declare function estimateTokens(text: string): number;
12
+ /** Estimated cost in micros for a set of token counts (or any other billed units) against
13
+ * an offering's or connection's own rates. Uses the exact same per-category round-up-then-
14
+ * sum arithmetic as the service's `costMicros` (`packages/service/src/provider.ts`), so a
15
+ * preview never reads as cheaper than what those same units would actually be charged.
16
+ * Throws on a unit with no rate, or a negative/non-integer/empty amount, exactly like the
17
+ * service does - never silently returns a wrong or negative estimate. */
18
+ export declare function estimateCostMicros(units: Record<string, string>, rates: Record<string, {
19
+ micros: string;
20
+ perUnits: string;
21
+ }>): string;
@@ -0,0 +1,46 @@
1
+ /** Local, network-free preview of spend before a run is submitted. Neither function is
2
+ * authoritative: the service still bounds real work by the model's own configured
3
+ * `maximumInputTokens`, and the real charge always comes from the provider's reported
4
+ * usage, settled through the service's own `costMicros`. These exist so a caller can
5
+ * preview spend before submitting, not to replace that settlement.
6
+ */
7
+ /** A fast, provider-agnostic approximation - roughly 4 characters per token for English
8
+ * text, the same rule of thumb OpenAI and Anthropic publish for a rough estimate. It is
9
+ * not any provider's real tokenizer and can be off by a wide margin for other scripts,
10
+ * code, or unusual token boundaries. */
11
+ export function estimateTokens(text) {
12
+ return Math.ceil(text.length / 4);
13
+ }
14
+ // Same shape the service's own amountSchema requires (packages/service/src/money.ts):
15
+ // digits only, no sign, no decimal point, no leading zero. `BigInt('-1')` and
16
+ // `BigInt('1e5')` do not agree on that - the former silently parses to a negative
17
+ // value and the latter throws only by luck of syntax - so every amount is checked
18
+ // against this pattern before use rather than handed straight to `BigInt`.
19
+ const amountPattern = /^(0|[1-9][0-9]*)$/;
20
+ function amount(value, label) {
21
+ if (!amountPattern.test(value))
22
+ throw new Error(`Not a valid non-negative amount for ${label}: ${value}`);
23
+ return BigInt(value);
24
+ }
25
+ /** Estimated cost in micros for a set of token counts (or any other billed units) against
26
+ * an offering's or connection's own rates. Uses the exact same per-category round-up-then-
27
+ * sum arithmetic as the service's `costMicros` (`packages/service/src/provider.ts`), so a
28
+ * preview never reads as cheaper than what those same units would actually be charged.
29
+ * Throws on a unit with no rate, or a negative/non-integer/empty amount, exactly like the
30
+ * service does - never silently returns a wrong or negative estimate. */
31
+ export function estimateCostMicros(units, rates) {
32
+ let total = 0n;
33
+ for (const [unit, quantity] of Object.entries(units)) {
34
+ const rate = rates[unit];
35
+ if (rate === undefined)
36
+ throw new Error(`Unpriced consumption unit: ${unit}`);
37
+ const numerator = amount(quantity, unit) * amount(rate.micros, `${unit}.micros`);
38
+ const denominator = amount(rate.perUnits, `${unit}.perUnits`);
39
+ if (denominator === 0n)
40
+ throw new Error(`Rate denominator must be positive: ${unit}`);
41
+ // Round each category up to one micro-dollar only after multiplication, same as the
42
+ // service, so the two never disagree by a rounding direction.
43
+ total += (numerator + denominator - 1n) / denominator;
44
+ }
45
+ return total.toString();
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './client.js';
2
+ export * from './estimate.js';
2
3
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from './client.js';
2
+ export * from './estimate.js';
@@ -8,7 +8,17 @@ export interface ClientOptions {
8
8
  export declare class ApiError extends Error {
9
9
  readonly status: number;
10
10
  readonly code: string;
11
- constructor(status: number, code: string);
11
+ /** Seconds until the caller may retry, read off the response's `Retry-After`
12
+ * header (service/http.ts sends it only on `rate_limited`). `undefined` when
13
+ * absent or not a plain positive integer of at most a week - this never retries
14
+ * on its own. */
15
+ readonly retryAfterSeconds?: number | undefined;
16
+ constructor(status: number, code: string,
17
+ /** Seconds until the caller may retry, read off the response's `Retry-After`
18
+ * header (service/http.ts sends it only on `rate_limited`). `undefined` when
19
+ * absent or not a plain positive integer of at most a week - this never retries
20
+ * on its own. */
21
+ retryAfterSeconds?: number | undefined);
12
22
  }
13
23
  export declare function clientCall(options: ClientOptions): <T>(method: string, path: string, input?: unknown, format?: "json" | "text", timeoutMs?: number) => Promise<T>;
14
24
  /** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
package/dist/transport.js CHANGED
@@ -1,12 +1,33 @@
1
1
  export class ApiError extends Error {
2
2
  status;
3
3
  code;
4
- constructor(status, code) {
4
+ retryAfterSeconds;
5
+ constructor(status, code,
6
+ /** Seconds until the caller may retry, read off the response's `Retry-After`
7
+ * header (service/http.ts sends it only on `rate_limited`). `undefined` when
8
+ * absent or not a plain positive integer of at most a week - this never retries
9
+ * on its own. */
10
+ retryAfterSeconds) {
5
11
  super(code);
6
12
  this.status = status;
7
13
  this.code = code;
14
+ this.retryAfterSeconds = retryAfterSeconds;
8
15
  }
9
16
  }
17
+ /** A week. A hint further out than this is not a delay a caller can wait for, so it
18
+ * is read as a broken header instead. */
19
+ const maxRetryAfterSeconds = 604_800;
20
+ /** A sane positive-integer `Retry-After` in seconds, or `undefined` for a missing,
21
+ * fractional, non-numeric, non-positive or implausibly distant value (never `NaN`).
22
+ * The header comes from whatever server the SDK is pointed at, so only plain decimal
23
+ * digits count: `Number` alone would read `0x2A` as 42 and twenty-five nines as 1e25. */
24
+ function retryAfter(headers) {
25
+ const raw = headers.get('retry-after');
26
+ if (raw === null || !/^[0-9]+$/.test(raw))
27
+ return undefined;
28
+ const seconds = Number(raw);
29
+ return seconds > 0 && seconds <= maxRetryAfterSeconds ? seconds : undefined;
30
+ }
10
31
  function validatedBase(url) {
11
32
  const base = new URL(url);
12
33
  if (base.username ||
@@ -19,6 +40,17 @@ function validatedBase(url) {
19
40
  throw new Error('Use an HTTPS service origin (HTTP is allowed only for localhost)');
20
41
  return base;
21
42
  }
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => setTimeout(resolve, ms));
45
+ }
46
+ /** Bounds for the SDK's own automatic retry, independent of the much larger
47
+ * `maxRetryAfterSeconds` sanity bound `retryAfter` validates against: a caller who
48
+ * gets `ApiError` back (because the server asked for a longer wait, or attempts ran
49
+ * out) can still retry by hand using `retryAfterSeconds`, exactly as before this
50
+ * existed. This only automates the short, common rate-limited case so every consumer
51
+ * stops hand-rolling that loop themselves. */
52
+ const maxAutoRetries = 2;
53
+ const maxAutoRetryDelaySeconds = 5;
22
54
  function errorCode(value) {
23
55
  return typeof value === 'object' &&
24
56
  value !== null &&
@@ -33,34 +65,47 @@ function errorCode(value) {
33
65
  export function clientCall(options) {
34
66
  const base = validatedBase(options.url);
35
67
  return async (method, path, input, format = 'json', timeoutMs = 30000) => {
36
- const response = await (options.fetch ?? fetch)(new URL(path, base), {
37
- method,
38
- redirect: 'error',
39
- credentials: 'omit',
40
- headers: {
41
- ...(options.organisationId ? { 'x-organisation-id': options.organisationId } : {}),
42
- authorization: `Bearer ${await options.credential()}`,
43
- ...(input === undefined ? {} : { 'content-type': 'application/json' }),
44
- },
45
- body: input === undefined ? undefined : JSON.stringify(input),
46
- signal: AbortSignal.timeout(timeoutMs),
47
- });
48
- if (response.status === 204)
49
- return undefined;
50
- if (format === 'text' && response.ok)
51
- return (await response.text());
52
- let value;
53
- try {
54
- value = await response.json();
55
- }
56
- catch {
57
- throw new ApiError(response.status, 'invalid_response');
68
+ for (let retries = 0;; retries++) {
69
+ const response = await (options.fetch ?? fetch)(new URL(path, base), {
70
+ method,
71
+ redirect: 'error',
72
+ credentials: 'omit',
73
+ headers: {
74
+ ...(options.organisationId ? { 'x-organisation-id': options.organisationId } : {}),
75
+ authorization: `Bearer ${await options.credential()}`,
76
+ ...(input === undefined ? {} : { 'content-type': 'application/json' }),
77
+ },
78
+ body: input === undefined ? undefined : JSON.stringify(input),
79
+ signal: AbortSignal.timeout(timeoutMs),
80
+ });
81
+ if (response.status === 204)
82
+ return undefined;
83
+ if (format === 'text' && response.ok)
84
+ return (await response.text());
85
+ let value;
86
+ try {
87
+ value = await response.json();
88
+ }
89
+ catch {
90
+ throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
91
+ }
92
+ if (!response.ok) {
93
+ const wait = retryAfter(response.headers);
94
+ // Only a server-declared, short `Retry-After` auto-retries: the request is
95
+ // provably unprocessed (service/http.ts only sends this header on
96
+ // `rate_limited`), so replaying it is always safe, regardless of whether this
97
+ // particular call happens to be idempotent on its own. A longer wait, or a
98
+ // request with no such header, surfaces immediately - the caller decides.
99
+ if (wait !== undefined && wait <= maxAutoRetryDelaySeconds && retries < maxAutoRetries) {
100
+ await sleep(wait * 1000);
101
+ continue;
102
+ }
103
+ throw new ApiError(response.status, errorCode(value), wait);
104
+ }
105
+ if (!value || typeof value !== 'object' || !('data' in value))
106
+ throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
107
+ return value.data;
58
108
  }
59
- if (!response.ok)
60
- throw new ApiError(response.status, errorCode(value));
61
- if (!value || typeof value !== 'object' || !('data' in value))
62
- throw new ApiError(response.status, 'invalid_response');
63
- return value.data;
64
109
  };
65
110
  }
66
111
  /** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
@@ -90,9 +135,9 @@ export function clientStream(options) {
90
135
  value = await response.json();
91
136
  }
92
137
  catch {
93
- throw new ApiError(response.status, 'invalid_response');
138
+ throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
94
139
  }
95
- throw new ApiError(response.status, errorCode(value));
140
+ throw new ApiError(response.status, errorCode(value), retryAfter(response.headers));
96
141
  }
97
142
  const reader = response.body.getReader();
98
143
  const decoder = new TextDecoder();
package/dist/types.d.ts CHANGED
@@ -15,6 +15,22 @@ export interface RunRequest {
15
15
  * Refused before anything is reserved if the resolved model cannot stream. */
16
16
  stream?: boolean;
17
17
  }
18
+ /** Observed consumption, independent of customer price or upstream cost. Omitted
19
+ * categories are unknown, not zero. A partial snapshot may contain lower bounds. */
20
+ export interface ConsumptionView {
21
+ units: Record<string, string>;
22
+ status: 'complete' | 'partial' | 'unavailable';
23
+ source: 'provider_reported' | 'adapter_derived';
24
+ }
25
+ export interface ConsumptionRecordView {
26
+ runId: string;
27
+ keyId: string | null;
28
+ model: string;
29
+ attributionRef: string | null;
30
+ outcome: 'succeeded' | 'failed' | 'cancelled';
31
+ consumption: ConsumptionView;
32
+ recordedAt: string;
33
+ }
18
34
  export interface RunView {
19
35
  id: string;
20
36
  state: 'queued' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'cancelled';
@@ -30,11 +46,24 @@ export interface RunView {
30
46
  message: string;
31
47
  at: string;
32
48
  } | null;
49
+ /** Null before a measured result or for historical/uninstrumented adapters. */
50
+ consumption: ConsumptionView | null;
33
51
  }
34
52
  /** One event of a `stream: true` run, as `AiClient.stream` yields them: `delta` for
35
53
  * each piece of text the provider produced, and exactly one terminal `done` carrying
36
54
  * the same view `GET /v1/runs/:id` would show once the stream ends, however it ends
37
55
  * (full completion, a disconnect that still settled, or a provider error mid-stream). */
56
+ /** One item of `POST /v1/runs/batch`, in request order: the submitted (queued or
57
+ * replayed) run, or the same `{ code, message }` a single `POST /v1/runs` would have
58
+ * failed with. One item's error never affects the others. */
59
+ export type BatchRunResult = {
60
+ run: RunView;
61
+ } | {
62
+ error: {
63
+ code: string;
64
+ message: string;
65
+ };
66
+ };
38
67
  export type RunStreamEvent = {
39
68
  type: 'delta';
40
69
  text: string;
@@ -85,6 +114,9 @@ export interface CreateOffering {
85
114
  micros: string;
86
115
  perUnits: string;
87
116
  }>;
117
+ /** Platform-only forge binding selected by the operator. The binding's
118
+ * endpoint, account and transport secret are never part of this request. */
119
+ executionBindingId?: string;
88
120
  credential: string;
89
121
  }
90
122
  export interface UpdateOffering {
@@ -182,6 +214,13 @@ export interface WebhookView {
182
214
  secretRotatedAt: string;
183
215
  previousSecretValidUntil: string | null;
184
216
  }
217
+ /** Only creation and rotation disclose a secret. Persist it in the receiver's
218
+ * secret store immediately; ordinary reads cannot recover it. */
219
+ export interface WebhookProvisioning {
220
+ webhook: WebhookView;
221
+ secret: string;
222
+ }
223
+ export type SetWebhookResult = WebhookProvisioning | WebhookView;
185
224
  export interface WebhookDeliveryView {
186
225
  id: string;
187
226
  runId: string;
@@ -3,7 +3,7 @@
3
3
  * A new receiver needs nothing but this header name to verify against a fresh secret.
4
4
  */
5
5
  export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
6
- export declare function signWebhookPayload(secret: string, body: string, timestamp: number): string;
6
+ export declare function signWebhookPayload(secret: string | readonly string[], body: string, timestamp: number): string;
7
7
  /** Verifies a signature header against every currently-valid secret (the active one,
8
8
  * plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
9
9
  * on either secret is accepted. Each candidate is compared in constant time.
@@ -6,25 +6,36 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
6
6
  */
7
7
  export const SIGNATURE_TOLERANCE_SECONDS = 300;
8
8
  export function signWebhookPayload(secret, body, timestamp) {
9
- const mac = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
10
- return `t=${timestamp},v1=${mac}`;
9
+ const secrets = typeof secret === 'string' ? [secret] : secret;
10
+ if (secrets.length === 0)
11
+ throw new Error('At least one signing secret is required');
12
+ const signatures = secrets.map((key) => `v1=${createHmac('sha256', key).update(`${timestamp}.${body}`).digest('hex')}`);
13
+ return `t=${timestamp},${signatures.join(',')}`;
11
14
  }
12
- const HEADER_PATTERN = /^t=(\d+),v1=([0-9a-f]{64})$/;
15
+ // Require one timestamp and well-formed signatures, preserving repeated v1 keys.
16
+ const HEADER_PATTERN = /^t=(\d+)((?:,v1=[0-9a-f]{64})+)$/;
13
17
  /** Verifies a signature header against every currently-valid secret (the active one,
14
18
  * plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
15
19
  * on either secret is accepted. Each candidate is compared in constant time.
16
20
  */
17
21
  export function verifyWebhookSignature(header, secrets, body, now, toleranceSeconds = SIGNATURE_TOLERANCE_SECONDS) {
18
22
  const match = HEADER_PATTERN.exec(header);
19
- if (!match)
23
+ if (!match || match[0] !== header)
20
24
  return false;
21
- const [, timestampText, signatureHex] = match;
25
+ const [, timestampText, signatures] = match;
22
26
  const timestamp = Number(timestampText);
23
- if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > toleranceSeconds)
27
+ if (!Number.isSafeInteger(timestamp) ||
28
+ !Number.isFinite(now) ||
29
+ !Number.isFinite(toleranceSeconds) ||
30
+ toleranceSeconds < 0 ||
31
+ Math.abs(now - timestamp) > toleranceSeconds)
24
32
  return false;
25
- const provided = Buffer.from(signatureHex, 'hex');
33
+ const provided = signatures
34
+ .slice(1)
35
+ .split(',')
36
+ .map((part) => Buffer.from(part.slice(3), 'hex'));
26
37
  return secrets.some((secret) => {
27
38
  const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'), 'hex');
28
- return provided.length === expected.length && timingSafeEqual(provided, expected);
39
+ return provided.some((candidate) => timingSafeEqual(candidate, expected));
29
40
  });
30
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/ai",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -16,6 +16,8 @@
16
16
  "dist/index.d.ts",
17
17
  "dist/client.js",
18
18
  "dist/client.d.ts",
19
+ "dist/estimate.js",
20
+ "dist/estimate.d.ts",
19
21
  "dist/transport.js",
20
22
  "dist/transport.d.ts",
21
23
  "dist/types.js",