@osmapi/osmtalk-sdk 0.2.0 → 0.3.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/dist/index.d.mts CHANGED
@@ -10,12 +10,45 @@
10
10
  * dynamicVariables: { first_name: "Arjun" },
11
11
  * });
12
12
  */
13
+ /** Bumped on every release — surfaced in the User-Agent header. */
14
+ declare const SDK_VERSION = "0.3.0";
13
15
  interface OsmtalkOptions {
14
16
  apiKey: string;
15
17
  baseUrl?: string;
18
+ /** Per-request timeout. Default 30s. Set `0` to disable. */
16
19
  timeoutMs?: number;
17
20
  /** Custom fetch implementation (e.g. for testing). */
18
21
  fetch?: typeof fetch;
22
+ /**
23
+ * Maximum auto-retries on transient failures (5xx, 429, network
24
+ * errors). Default 2. Set to 0 to disable. Mutating requests (POST,
25
+ * PUT, DELETE) are only retried when an `idempotencyKey` is set on
26
+ * the call — otherwise a retry could double-charge or double-place
27
+ * a call.
28
+ */
29
+ maxRetries?: number;
30
+ /**
31
+ * Initial retry delay in ms. Doubled on each subsequent retry.
32
+ * Default 250ms → 500 → 1000. Server `Retry-After` headers take
33
+ * precedence when present.
34
+ */
35
+ retryInitialDelayMs?: number;
36
+ /** Extra headers added to every request (e.g. observability IDs). */
37
+ defaultHeaders?: Record<string, string>;
38
+ /**
39
+ * Org ID to send as `X-Organization-Id`. For users in multiple orgs;
40
+ * defaults to the API key's primary org if omitted.
41
+ */
42
+ organizationId?: string;
43
+ }
44
+ interface RequestOptions {
45
+ idempotencyKey?: string;
46
+ /** AbortSignal for caller-side cancellation. Combined with the SDK's timeout signal. */
47
+ signal?: AbortSignal;
48
+ /** Per-call override of the global timeout. */
49
+ timeoutMs?: number;
50
+ /** Per-call override of the org ID. */
51
+ organizationId?: string;
19
52
  }
20
53
  interface DynamicVariables {
21
54
  [key: string]: string | number | boolean;
@@ -141,15 +174,36 @@ interface OsmtalkErrorBody {
141
174
  declare class OsmtalkError extends Error {
142
175
  readonly status: number;
143
176
  readonly body: OsmtalkErrorBody;
144
- constructor(status: number, body: OsmtalkErrorBody);
177
+ /** Number of retry attempts the SDK made before giving up. */
178
+ readonly retryAttempts: number;
179
+ constructor(status: number, body: OsmtalkErrorBody, retryAttempts?: number);
180
+ /** True for status codes the server might recover from on retry. */
181
+ get isRetryable(): boolean;
182
+ /** True for client mistakes (bad input, bad auth). */
183
+ get isClientError(): boolean;
145
184
  }
146
185
  declare class HttpClient {
147
186
  private readonly baseUrl;
148
187
  private readonly apiKey;
149
188
  private readonly timeoutMs;
189
+ private readonly maxRetries;
190
+ private readonly retryInitialDelayMs;
150
191
  private readonly fetchImpl;
192
+ private readonly defaultHeaders;
193
+ private readonly organizationId;
194
+ private readonly userAgent;
151
195
  constructor(opts: OsmtalkOptions);
152
- request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string): Promise<T>;
196
+ /**
197
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
198
+ * signal so EITHER firing cancels the fetch. We can't use
199
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
200
+ */
201
+ private buildAbortSignal;
202
+ /** Parse the body once for both success and error paths. */
203
+ private static parseBody;
204
+ /** How long to wait before the next retry. Honors Retry-After. */
205
+ private retryDelay;
206
+ request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string, options?: RequestOptions): Promise<T>;
153
207
  }
154
208
  declare class AgentsResource {
155
209
  private readonly http;
@@ -199,27 +253,46 @@ declare class AgentsResource {
199
253
  callId: string;
200
254
  }>;
201
255
  }
256
+ /** Call.status values that mean "no more state changes are coming". */
257
+ declare const TERMINAL_CALL_STATUSES: readonly ["completed", "failed", "ended", "cancelled"];
202
258
  declare class CallsResource {
203
259
  private readonly http;
204
260
  constructor(http: HttpClient);
205
- list(): Promise<CallRecord[]>;
206
- get(id: string): Promise<CallRecord>;
261
+ list(opts?: RequestOptions): Promise<CallRecord[]>;
262
+ get(id: string, opts?: RequestOptions): Promise<CallRecord>;
207
263
  /**
208
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
209
- * within 24h return the same response instead of placing a duplicate call.
264
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
265
+ * returns the same response instead of placing a duplicate call
266
+ * required if you want this call to be retried on transient failures.
210
267
  */
211
- outbound(input: CallStartRequest, opts?: {
212
- idempotencyKey?: string;
213
- }): Promise<{
268
+ outbound(input: CallStartRequest, opts?: RequestOptions): Promise<{
214
269
  callId: string;
215
270
  roomName: string;
216
271
  }>;
217
- end(id: string): Promise<{
272
+ end(id: string, opts?: RequestOptions): Promise<{
218
273
  success: true;
219
274
  }>;
220
- transfer(id: string, destination: string, summary?: string): Promise<{
275
+ transfer(id: string, destination: string, summary?: string, opts?: RequestOptions): Promise<{
221
276
  success: true;
222
277
  }>;
278
+ /**
279
+ * Poll a call until it reaches a terminal status (`completed`,
280
+ * `failed`, `ended`, `cancelled`) and return the final record.
281
+ *
282
+ * Saves consumers from writing the same loop in every script. Use
283
+ * webhooks instead in production — this is fine for scripts, demos,
284
+ * and one-off jobs but consumes API quota on every poll.
285
+ *
286
+ * @param opts.pollIntervalMs - default 5s
287
+ * @param opts.timeoutMs - default 30min (rejects with
288
+ * `OsmtalkError` 408 on timeout)
289
+ * @param opts.signal - abort externally
290
+ */
291
+ waitUntilEnded(id: string, opts?: {
292
+ pollIntervalMs?: number;
293
+ timeoutMs?: number;
294
+ signal?: AbortSignal;
295
+ }): Promise<CallRecord>;
223
296
  }
224
297
  declare class PlatformResource {
225
298
  private readonly http;
@@ -517,4 +590,4 @@ declare class Osmtalk {
517
590
  constructor(opts: OsmtalkOptions);
518
591
  }
519
592
 
520
- export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
593
+ export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, type RequestOptions, SDK_VERSION, TERMINAL_CALL_STATUSES, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
package/dist/index.d.ts CHANGED
@@ -10,12 +10,45 @@
10
10
  * dynamicVariables: { first_name: "Arjun" },
11
11
  * });
12
12
  */
13
+ /** Bumped on every release — surfaced in the User-Agent header. */
14
+ declare const SDK_VERSION = "0.3.0";
13
15
  interface OsmtalkOptions {
14
16
  apiKey: string;
15
17
  baseUrl?: string;
18
+ /** Per-request timeout. Default 30s. Set `0` to disable. */
16
19
  timeoutMs?: number;
17
20
  /** Custom fetch implementation (e.g. for testing). */
18
21
  fetch?: typeof fetch;
22
+ /**
23
+ * Maximum auto-retries on transient failures (5xx, 429, network
24
+ * errors). Default 2. Set to 0 to disable. Mutating requests (POST,
25
+ * PUT, DELETE) are only retried when an `idempotencyKey` is set on
26
+ * the call — otherwise a retry could double-charge or double-place
27
+ * a call.
28
+ */
29
+ maxRetries?: number;
30
+ /**
31
+ * Initial retry delay in ms. Doubled on each subsequent retry.
32
+ * Default 250ms → 500 → 1000. Server `Retry-After` headers take
33
+ * precedence when present.
34
+ */
35
+ retryInitialDelayMs?: number;
36
+ /** Extra headers added to every request (e.g. observability IDs). */
37
+ defaultHeaders?: Record<string, string>;
38
+ /**
39
+ * Org ID to send as `X-Organization-Id`. For users in multiple orgs;
40
+ * defaults to the API key's primary org if omitted.
41
+ */
42
+ organizationId?: string;
43
+ }
44
+ interface RequestOptions {
45
+ idempotencyKey?: string;
46
+ /** AbortSignal for caller-side cancellation. Combined with the SDK's timeout signal. */
47
+ signal?: AbortSignal;
48
+ /** Per-call override of the global timeout. */
49
+ timeoutMs?: number;
50
+ /** Per-call override of the org ID. */
51
+ organizationId?: string;
19
52
  }
20
53
  interface DynamicVariables {
21
54
  [key: string]: string | number | boolean;
@@ -141,15 +174,36 @@ interface OsmtalkErrorBody {
141
174
  declare class OsmtalkError extends Error {
142
175
  readonly status: number;
143
176
  readonly body: OsmtalkErrorBody;
144
- constructor(status: number, body: OsmtalkErrorBody);
177
+ /** Number of retry attempts the SDK made before giving up. */
178
+ readonly retryAttempts: number;
179
+ constructor(status: number, body: OsmtalkErrorBody, retryAttempts?: number);
180
+ /** True for status codes the server might recover from on retry. */
181
+ get isRetryable(): boolean;
182
+ /** True for client mistakes (bad input, bad auth). */
183
+ get isClientError(): boolean;
145
184
  }
146
185
  declare class HttpClient {
147
186
  private readonly baseUrl;
148
187
  private readonly apiKey;
149
188
  private readonly timeoutMs;
189
+ private readonly maxRetries;
190
+ private readonly retryInitialDelayMs;
150
191
  private readonly fetchImpl;
192
+ private readonly defaultHeaders;
193
+ private readonly organizationId;
194
+ private readonly userAgent;
151
195
  constructor(opts: OsmtalkOptions);
152
- request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string): Promise<T>;
196
+ /**
197
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
198
+ * signal so EITHER firing cancels the fetch. We can't use
199
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
200
+ */
201
+ private buildAbortSignal;
202
+ /** Parse the body once for both success and error paths. */
203
+ private static parseBody;
204
+ /** How long to wait before the next retry. Honors Retry-After. */
205
+ private retryDelay;
206
+ request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string, options?: RequestOptions): Promise<T>;
153
207
  }
154
208
  declare class AgentsResource {
155
209
  private readonly http;
@@ -199,27 +253,46 @@ declare class AgentsResource {
199
253
  callId: string;
200
254
  }>;
201
255
  }
256
+ /** Call.status values that mean "no more state changes are coming". */
257
+ declare const TERMINAL_CALL_STATUSES: readonly ["completed", "failed", "ended", "cancelled"];
202
258
  declare class CallsResource {
203
259
  private readonly http;
204
260
  constructor(http: HttpClient);
205
- list(): Promise<CallRecord[]>;
206
- get(id: string): Promise<CallRecord>;
261
+ list(opts?: RequestOptions): Promise<CallRecord[]>;
262
+ get(id: string, opts?: RequestOptions): Promise<CallRecord>;
207
263
  /**
208
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
209
- * within 24h return the same response instead of placing a duplicate call.
264
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
265
+ * returns the same response instead of placing a duplicate call
266
+ * required if you want this call to be retried on transient failures.
210
267
  */
211
- outbound(input: CallStartRequest, opts?: {
212
- idempotencyKey?: string;
213
- }): Promise<{
268
+ outbound(input: CallStartRequest, opts?: RequestOptions): Promise<{
214
269
  callId: string;
215
270
  roomName: string;
216
271
  }>;
217
- end(id: string): Promise<{
272
+ end(id: string, opts?: RequestOptions): Promise<{
218
273
  success: true;
219
274
  }>;
220
- transfer(id: string, destination: string, summary?: string): Promise<{
275
+ transfer(id: string, destination: string, summary?: string, opts?: RequestOptions): Promise<{
221
276
  success: true;
222
277
  }>;
278
+ /**
279
+ * Poll a call until it reaches a terminal status (`completed`,
280
+ * `failed`, `ended`, `cancelled`) and return the final record.
281
+ *
282
+ * Saves consumers from writing the same loop in every script. Use
283
+ * webhooks instead in production — this is fine for scripts, demos,
284
+ * and one-off jobs but consumes API quota on every poll.
285
+ *
286
+ * @param opts.pollIntervalMs - default 5s
287
+ * @param opts.timeoutMs - default 30min (rejects with
288
+ * `OsmtalkError` 408 on timeout)
289
+ * @param opts.signal - abort externally
290
+ */
291
+ waitUntilEnded(id: string, opts?: {
292
+ pollIntervalMs?: number;
293
+ timeoutMs?: number;
294
+ signal?: AbortSignal;
295
+ }): Promise<CallRecord>;
223
296
  }
224
297
  declare class PlatformResource {
225
298
  private readonly http;
@@ -517,4 +590,4 @@ declare class Osmtalk {
517
590
  constructor(opts: OsmtalkOptions);
518
591
  }
519
592
 
520
- export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
593
+ export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, type RequestOptions, SDK_VERSION, TERMINAL_CALL_STATUSES, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
package/dist/index.js CHANGED
@@ -22,68 +22,196 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Osmtalk: () => Osmtalk,
24
24
  OsmtalkError: () => OsmtalkError,
25
+ SDK_VERSION: () => SDK_VERSION,
26
+ TERMINAL_CALL_STATUSES: () => TERMINAL_CALL_STATUSES,
25
27
  default: () => index_default,
26
28
  verifyWebhookSignature: () => verifyWebhookSignature,
27
29
  verifyWebhookSignatureAsync: () => verifyWebhookSignatureAsync
28
30
  });
29
31
  module.exports = __toCommonJS(index_exports);
32
+ var SDK_VERSION = "0.3.0";
30
33
  var OsmtalkError = class extends Error {
31
34
  status;
32
35
  body;
33
- constructor(status, body) {
36
+ /** Number of retry attempts the SDK made before giving up. */
37
+ retryAttempts;
38
+ constructor(status, body, retryAttempts = 0) {
34
39
  super(body?.error || body?.message || `osmTalk API error: ${status}`);
35
40
  this.name = "OsmtalkError";
36
41
  this.status = status;
37
42
  this.body = body;
43
+ this.retryAttempts = retryAttempts;
38
44
  }
45
+ /** True for status codes the server might recover from on retry. */
46
+ get isRetryable() {
47
+ return this.status === 408 || this.status === 429 || this.status >= 500;
48
+ }
49
+ /** True for client mistakes (bad input, bad auth). */
50
+ get isClientError() {
51
+ return this.status >= 400 && this.status < 500;
52
+ }
53
+ };
54
+ function runtimeTag() {
55
+ const g = globalThis;
56
+ if (g.Deno?.version?.deno) return `deno/${g.Deno.version.deno}`;
57
+ if (g.Bun?.version) return `bun/${g.Bun.version}`;
58
+ if (g.process?.versions?.node) return `node/${g.process.versions.node}`;
59
+ if (typeof navigator !== "undefined" && navigator.userAgent) return "browser";
60
+ return "unknown";
61
+ }
62
+ var STATUS_TEXT = {
63
+ 408: "Request Timeout",
64
+ 429: "Too Many Requests",
65
+ 500: "Internal Server Error",
66
+ 502: "Bad Gateway",
67
+ 503: "Service Unavailable",
68
+ 504: "Gateway Timeout"
39
69
  };
40
- var HttpClient = class {
70
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
71
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
72
+ var HttpClient = class _HttpClient {
41
73
  baseUrl;
42
74
  apiKey;
43
75
  timeoutMs;
76
+ maxRetries;
77
+ retryInitialDelayMs;
44
78
  fetchImpl;
79
+ defaultHeaders;
80
+ organizationId;
81
+ userAgent;
45
82
  constructor(opts) {
46
83
  this.apiKey = opts.apiKey;
47
84
  this.baseUrl = (opts.baseUrl ?? "https://api.osmtalk.com").replace(/\/$/, "");
48
85
  this.timeoutMs = opts.timeoutMs ?? 3e4;
86
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
87
+ this.retryInitialDelayMs = Math.max(0, opts.retryInitialDelayMs ?? 250);
88
+ this.defaultHeaders = opts.defaultHeaders ?? {};
89
+ this.organizationId = opts.organizationId;
90
+ this.userAgent = `@osmapi/osmtalk-sdk/${SDK_VERSION} ${runtimeTag()}`;
49
91
  this.fetchImpl = opts.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : void 0);
50
- if (!this.fetchImpl) throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
92
+ if (!this.fetchImpl) {
93
+ throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
94
+ }
51
95
  if (!opts.apiKey) throw new Error("apiKey is required");
52
96
  }
53
- async request(method, path, body, extraHeaders, idempotencyKey) {
97
+ /**
98
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
99
+ * signal so EITHER firing cancels the fetch. We can't use
100
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
101
+ */
102
+ buildAbortSignal(externalSignal, timeoutMs) {
54
103
  const ctrl = new AbortController();
55
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
104
+ const onAbort = () => ctrl.abort();
105
+ if (externalSignal) {
106
+ if (externalSignal.aborted) ctrl.abort();
107
+ else externalSignal.addEventListener("abort", onAbort, { once: true });
108
+ }
109
+ const timer = timeoutMs > 0 ? setTimeout(() => ctrl.abort(new Error("Request timed out")), timeoutMs) : null;
110
+ return {
111
+ signal: ctrl.signal,
112
+ cancel: () => {
113
+ if (timer) clearTimeout(timer);
114
+ if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
115
+ }
116
+ };
117
+ }
118
+ /** Parse the body once for both success and error paths. */
119
+ static async parseBody(res) {
120
+ const text = await res.text();
121
+ if (!text) return null;
122
+ try {
123
+ return JSON.parse(text);
124
+ } catch {
125
+ return text;
126
+ }
127
+ }
128
+ /** How long to wait before the next retry. Honors Retry-After. */
129
+ retryDelay(attempt, res) {
130
+ if (res) {
131
+ const retryAfter = res.headers.get("retry-after");
132
+ if (retryAfter) {
133
+ const secs = Number(retryAfter);
134
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, 3e4);
135
+ const when = Date.parse(retryAfter);
136
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 3e4));
137
+ }
138
+ }
139
+ const base = this.retryInitialDelayMs * 2 ** attempt;
140
+ const jitter = Math.floor(Math.random() * (base / 4));
141
+ return Math.min(base + jitter, 3e4);
142
+ }
143
+ async request(method, path, body, extraHeaders, idempotencyKey, options) {
144
+ const reqOrgId = options?.organizationId ?? this.organizationId;
145
+ const reqTimeout = options?.timeoutMs ?? this.timeoutMs;
56
146
  const headers = {
57
147
  Authorization: `Bearer ${this.apiKey}`,
148
+ "User-Agent": this.userAgent,
149
+ Accept: "application/json",
58
150
  ...body !== void 0 ? { "Content-Type": "application/json" } : {},
59
151
  ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
152
+ ...reqOrgId ? { "X-Organization-Id": reqOrgId } : {},
153
+ ...this.defaultHeaders,
60
154
  ...extraHeaders ?? {}
61
155
  };
62
- let res;
63
- try {
64
- res = await this.fetchImpl(`${this.baseUrl}${path}`, {
65
- method,
66
- headers,
67
- body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body),
68
- signal: ctrl.signal
69
- });
70
- } finally {
71
- clearTimeout(timer);
72
- }
73
- const text = await res.text();
74
- const parsed = text ? (() => {
156
+ const serializedBody = body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body);
157
+ const canRetryMutation = SAFE_METHODS.has(method.toUpperCase()) || Boolean(idempotencyKey);
158
+ const url = `${this.baseUrl}${path}`;
159
+ let lastErr;
160
+ let lastRes = null;
161
+ const maxAttempts = this.maxRetries + 1;
162
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
163
+ const { signal, cancel } = this.buildAbortSignal(options?.signal, reqTimeout);
164
+ let res = null;
75
165
  try {
76
- return JSON.parse(text);
77
- } catch {
78
- return text;
166
+ res = await this.fetchImpl(url, {
167
+ method,
168
+ headers,
169
+ body: serializedBody,
170
+ signal
171
+ });
172
+ } catch (err) {
173
+ lastErr = err;
174
+ cancel();
175
+ if (options?.signal?.aborted) throw err;
176
+ if (attempt < maxAttempts - 1 && canRetryMutation) {
177
+ await sleep(this.retryDelay(attempt, null));
178
+ continue;
179
+ }
180
+ throw err;
181
+ }
182
+ cancel();
183
+ if (res.ok) {
184
+ return await _HttpClient.parseBody(res);
79
185
  }
80
- })() : null;
81
- if (!res.ok) {
82
- throw new OsmtalkError(res.status, parsed ?? { error: text });
186
+ const retryable = RETRYABLE_STATUSES.has(res.status);
187
+ if (retryable && canRetryMutation && attempt < maxAttempts - 1) {
188
+ try {
189
+ await res.text();
190
+ } catch {
191
+ }
192
+ await sleep(this.retryDelay(attempt, res));
193
+ lastRes = res;
194
+ continue;
195
+ }
196
+ const parsed = await _HttpClient.parseBody(res);
197
+ throw new OsmtalkError(
198
+ res.status,
199
+ parsed ?? { error: STATUS_TEXT[res.status] ?? `HTTP ${res.status}` },
200
+ attempt
201
+ );
202
+ }
203
+ if (lastRes) {
204
+ const parsed = await _HttpClient.parseBody(lastRes);
205
+ throw new OsmtalkError(
206
+ lastRes.status,
207
+ parsed ?? { error: STATUS_TEXT[lastRes.status] ?? "Server error" },
208
+ maxAttempts - 1
209
+ );
83
210
  }
84
- return parsed;
211
+ throw lastErr ?? new Error("osmTalk SDK: request failed without details");
85
212
  }
86
213
  };
214
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
87
215
  var AgentsResource = class {
88
216
  constructor(http) {
89
217
  this.http = http;
@@ -139,20 +267,22 @@ var AgentsResource = class {
139
267
  );
140
268
  }
141
269
  };
270
+ var TERMINAL_CALL_STATUSES = ["completed", "failed", "ended", "cancelled"];
142
271
  var CallsResource = class {
143
272
  constructor(http) {
144
273
  this.http = http;
145
274
  }
146
275
  http;
147
- list() {
148
- return this.http.request("GET", "/api/calls");
276
+ list(opts) {
277
+ return this.http.request("GET", "/api/calls", void 0, void 0, void 0, opts);
149
278
  }
150
- get(id) {
151
- return this.http.request("GET", `/api/calls/${id}`);
279
+ get(id, opts) {
280
+ return this.http.request("GET", `/api/calls/${id}`, void 0, void 0, void 0, opts);
152
281
  }
153
282
  /**
154
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
155
- * within 24h return the same response instead of placing a duplicate call.
283
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
284
+ * returns the same response instead of placing a duplicate call
285
+ * required if you want this call to be retried on transient failures.
156
286
  */
157
287
  outbound(input, opts) {
158
288
  return this.http.request(
@@ -160,14 +290,61 @@ var CallsResource = class {
160
290
  "/api/calls/outbound",
161
291
  input,
162
292
  void 0,
163
- opts?.idempotencyKey
293
+ opts?.idempotencyKey,
294
+ opts
295
+ );
296
+ }
297
+ end(id, opts) {
298
+ return this.http.request(
299
+ "POST",
300
+ `/api/calls/${id}/end`,
301
+ void 0,
302
+ void 0,
303
+ opts?.idempotencyKey,
304
+ opts
164
305
  );
165
306
  }
166
- end(id) {
167
- return this.http.request("POST", `/api/calls/${id}/end`);
307
+ transfer(id, destination, summary, opts) {
308
+ return this.http.request(
309
+ "POST",
310
+ `/api/calls/${id}/transfer`,
311
+ { destination, summary },
312
+ void 0,
313
+ opts?.idempotencyKey,
314
+ opts
315
+ );
168
316
  }
169
- transfer(id, destination, summary) {
170
- return this.http.request("POST", `/api/calls/${id}/transfer`, { destination, summary });
317
+ /**
318
+ * Poll a call until it reaches a terminal status (`completed`,
319
+ * `failed`, `ended`, `cancelled`) and return the final record.
320
+ *
321
+ * Saves consumers from writing the same loop in every script. Use
322
+ * webhooks instead in production — this is fine for scripts, demos,
323
+ * and one-off jobs but consumes API quota on every poll.
324
+ *
325
+ * @param opts.pollIntervalMs - default 5s
326
+ * @param opts.timeoutMs - default 30min (rejects with
327
+ * `OsmtalkError` 408 on timeout)
328
+ * @param opts.signal - abort externally
329
+ */
330
+ async waitUntilEnded(id, opts) {
331
+ const pollInterval = Math.max(1e3, opts?.pollIntervalMs ?? 5e3);
332
+ const totalTimeout = opts?.timeoutMs ?? 30 * 60 * 1e3;
333
+ const deadline = Date.now() + totalTimeout;
334
+ const terminal = new Set(TERMINAL_CALL_STATUSES);
335
+ while (true) {
336
+ if (opts?.signal?.aborted) {
337
+ throw new OsmtalkError(0, { error: "Aborted by caller" });
338
+ }
339
+ const call = await this.get(id, { signal: opts?.signal });
340
+ if (terminal.has(call.status)) return call;
341
+ if (Date.now() >= deadline) {
342
+ throw new OsmtalkError(408, {
343
+ error: `waitUntilEnded: call ${id} did not reach a terminal state within ${totalTimeout}ms (last status: ${call.status})`
344
+ });
345
+ }
346
+ await sleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())));
347
+ }
171
348
  }
172
349
  };
173
350
  var PlatformResource = class {
@@ -445,6 +622,8 @@ var index_default = Osmtalk;
445
622
  0 && (module.exports = {
446
623
  Osmtalk,
447
624
  OsmtalkError,
625
+ SDK_VERSION,
626
+ TERMINAL_CALL_STATUSES,
448
627
  verifyWebhookSignature,
449
628
  verifyWebhookSignatureAsync
450
629
  });
package/dist/index.mjs CHANGED
@@ -1,61 +1,187 @@
1
1
  // src/index.ts
2
+ var SDK_VERSION = "0.3.0";
2
3
  var OsmtalkError = class extends Error {
3
4
  status;
4
5
  body;
5
- constructor(status, body) {
6
+ /** Number of retry attempts the SDK made before giving up. */
7
+ retryAttempts;
8
+ constructor(status, body, retryAttempts = 0) {
6
9
  super(body?.error || body?.message || `osmTalk API error: ${status}`);
7
10
  this.name = "OsmtalkError";
8
11
  this.status = status;
9
12
  this.body = body;
13
+ this.retryAttempts = retryAttempts;
10
14
  }
15
+ /** True for status codes the server might recover from on retry. */
16
+ get isRetryable() {
17
+ return this.status === 408 || this.status === 429 || this.status >= 500;
18
+ }
19
+ /** True for client mistakes (bad input, bad auth). */
20
+ get isClientError() {
21
+ return this.status >= 400 && this.status < 500;
22
+ }
23
+ };
24
+ function runtimeTag() {
25
+ const g = globalThis;
26
+ if (g.Deno?.version?.deno) return `deno/${g.Deno.version.deno}`;
27
+ if (g.Bun?.version) return `bun/${g.Bun.version}`;
28
+ if (g.process?.versions?.node) return `node/${g.process.versions.node}`;
29
+ if (typeof navigator !== "undefined" && navigator.userAgent) return "browser";
30
+ return "unknown";
31
+ }
32
+ var STATUS_TEXT = {
33
+ 408: "Request Timeout",
34
+ 429: "Too Many Requests",
35
+ 500: "Internal Server Error",
36
+ 502: "Bad Gateway",
37
+ 503: "Service Unavailable",
38
+ 504: "Gateway Timeout"
11
39
  };
12
- var HttpClient = class {
40
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
41
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
42
+ var HttpClient = class _HttpClient {
13
43
  baseUrl;
14
44
  apiKey;
15
45
  timeoutMs;
46
+ maxRetries;
47
+ retryInitialDelayMs;
16
48
  fetchImpl;
49
+ defaultHeaders;
50
+ organizationId;
51
+ userAgent;
17
52
  constructor(opts) {
18
53
  this.apiKey = opts.apiKey;
19
54
  this.baseUrl = (opts.baseUrl ?? "https://api.osmtalk.com").replace(/\/$/, "");
20
55
  this.timeoutMs = opts.timeoutMs ?? 3e4;
56
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
57
+ this.retryInitialDelayMs = Math.max(0, opts.retryInitialDelayMs ?? 250);
58
+ this.defaultHeaders = opts.defaultHeaders ?? {};
59
+ this.organizationId = opts.organizationId;
60
+ this.userAgent = `@osmapi/osmtalk-sdk/${SDK_VERSION} ${runtimeTag()}`;
21
61
  this.fetchImpl = opts.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : void 0);
22
- if (!this.fetchImpl) throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
62
+ if (!this.fetchImpl) {
63
+ throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
64
+ }
23
65
  if (!opts.apiKey) throw new Error("apiKey is required");
24
66
  }
25
- async request(method, path, body, extraHeaders, idempotencyKey) {
67
+ /**
68
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
69
+ * signal so EITHER firing cancels the fetch. We can't use
70
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
71
+ */
72
+ buildAbortSignal(externalSignal, timeoutMs) {
26
73
  const ctrl = new AbortController();
27
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
74
+ const onAbort = () => ctrl.abort();
75
+ if (externalSignal) {
76
+ if (externalSignal.aborted) ctrl.abort();
77
+ else externalSignal.addEventListener("abort", onAbort, { once: true });
78
+ }
79
+ const timer = timeoutMs > 0 ? setTimeout(() => ctrl.abort(new Error("Request timed out")), timeoutMs) : null;
80
+ return {
81
+ signal: ctrl.signal,
82
+ cancel: () => {
83
+ if (timer) clearTimeout(timer);
84
+ if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
85
+ }
86
+ };
87
+ }
88
+ /** Parse the body once for both success and error paths. */
89
+ static async parseBody(res) {
90
+ const text = await res.text();
91
+ if (!text) return null;
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ return text;
96
+ }
97
+ }
98
+ /** How long to wait before the next retry. Honors Retry-After. */
99
+ retryDelay(attempt, res) {
100
+ if (res) {
101
+ const retryAfter = res.headers.get("retry-after");
102
+ if (retryAfter) {
103
+ const secs = Number(retryAfter);
104
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, 3e4);
105
+ const when = Date.parse(retryAfter);
106
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 3e4));
107
+ }
108
+ }
109
+ const base = this.retryInitialDelayMs * 2 ** attempt;
110
+ const jitter = Math.floor(Math.random() * (base / 4));
111
+ return Math.min(base + jitter, 3e4);
112
+ }
113
+ async request(method, path, body, extraHeaders, idempotencyKey, options) {
114
+ const reqOrgId = options?.organizationId ?? this.organizationId;
115
+ const reqTimeout = options?.timeoutMs ?? this.timeoutMs;
28
116
  const headers = {
29
117
  Authorization: `Bearer ${this.apiKey}`,
118
+ "User-Agent": this.userAgent,
119
+ Accept: "application/json",
30
120
  ...body !== void 0 ? { "Content-Type": "application/json" } : {},
31
121
  ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
122
+ ...reqOrgId ? { "X-Organization-Id": reqOrgId } : {},
123
+ ...this.defaultHeaders,
32
124
  ...extraHeaders ?? {}
33
125
  };
34
- let res;
35
- try {
36
- res = await this.fetchImpl(`${this.baseUrl}${path}`, {
37
- method,
38
- headers,
39
- body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body),
40
- signal: ctrl.signal
41
- });
42
- } finally {
43
- clearTimeout(timer);
44
- }
45
- const text = await res.text();
46
- const parsed = text ? (() => {
126
+ const serializedBody = body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body);
127
+ const canRetryMutation = SAFE_METHODS.has(method.toUpperCase()) || Boolean(idempotencyKey);
128
+ const url = `${this.baseUrl}${path}`;
129
+ let lastErr;
130
+ let lastRes = null;
131
+ const maxAttempts = this.maxRetries + 1;
132
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
133
+ const { signal, cancel } = this.buildAbortSignal(options?.signal, reqTimeout);
134
+ let res = null;
47
135
  try {
48
- return JSON.parse(text);
49
- } catch {
50
- return text;
136
+ res = await this.fetchImpl(url, {
137
+ method,
138
+ headers,
139
+ body: serializedBody,
140
+ signal
141
+ });
142
+ } catch (err) {
143
+ lastErr = err;
144
+ cancel();
145
+ if (options?.signal?.aborted) throw err;
146
+ if (attempt < maxAttempts - 1 && canRetryMutation) {
147
+ await sleep(this.retryDelay(attempt, null));
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
152
+ cancel();
153
+ if (res.ok) {
154
+ return await _HttpClient.parseBody(res);
51
155
  }
52
- })() : null;
53
- if (!res.ok) {
54
- throw new OsmtalkError(res.status, parsed ?? { error: text });
156
+ const retryable = RETRYABLE_STATUSES.has(res.status);
157
+ if (retryable && canRetryMutation && attempt < maxAttempts - 1) {
158
+ try {
159
+ await res.text();
160
+ } catch {
161
+ }
162
+ await sleep(this.retryDelay(attempt, res));
163
+ lastRes = res;
164
+ continue;
165
+ }
166
+ const parsed = await _HttpClient.parseBody(res);
167
+ throw new OsmtalkError(
168
+ res.status,
169
+ parsed ?? { error: STATUS_TEXT[res.status] ?? `HTTP ${res.status}` },
170
+ attempt
171
+ );
172
+ }
173
+ if (lastRes) {
174
+ const parsed = await _HttpClient.parseBody(lastRes);
175
+ throw new OsmtalkError(
176
+ lastRes.status,
177
+ parsed ?? { error: STATUS_TEXT[lastRes.status] ?? "Server error" },
178
+ maxAttempts - 1
179
+ );
55
180
  }
56
- return parsed;
181
+ throw lastErr ?? new Error("osmTalk SDK: request failed without details");
57
182
  }
58
183
  };
184
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
185
  var AgentsResource = class {
60
186
  constructor(http) {
61
187
  this.http = http;
@@ -111,20 +237,22 @@ var AgentsResource = class {
111
237
  );
112
238
  }
113
239
  };
240
+ var TERMINAL_CALL_STATUSES = ["completed", "failed", "ended", "cancelled"];
114
241
  var CallsResource = class {
115
242
  constructor(http) {
116
243
  this.http = http;
117
244
  }
118
245
  http;
119
- list() {
120
- return this.http.request("GET", "/api/calls");
246
+ list(opts) {
247
+ return this.http.request("GET", "/api/calls", void 0, void 0, void 0, opts);
121
248
  }
122
- get(id) {
123
- return this.http.request("GET", `/api/calls/${id}`);
249
+ get(id, opts) {
250
+ return this.http.request("GET", `/api/calls/${id}`, void 0, void 0, void 0, opts);
124
251
  }
125
252
  /**
126
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
127
- * within 24h return the same response instead of placing a duplicate call.
253
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
254
+ * returns the same response instead of placing a duplicate call
255
+ * required if you want this call to be retried on transient failures.
128
256
  */
129
257
  outbound(input, opts) {
130
258
  return this.http.request(
@@ -132,14 +260,61 @@ var CallsResource = class {
132
260
  "/api/calls/outbound",
133
261
  input,
134
262
  void 0,
135
- opts?.idempotencyKey
263
+ opts?.idempotencyKey,
264
+ opts
265
+ );
266
+ }
267
+ end(id, opts) {
268
+ return this.http.request(
269
+ "POST",
270
+ `/api/calls/${id}/end`,
271
+ void 0,
272
+ void 0,
273
+ opts?.idempotencyKey,
274
+ opts
136
275
  );
137
276
  }
138
- end(id) {
139
- return this.http.request("POST", `/api/calls/${id}/end`);
277
+ transfer(id, destination, summary, opts) {
278
+ return this.http.request(
279
+ "POST",
280
+ `/api/calls/${id}/transfer`,
281
+ { destination, summary },
282
+ void 0,
283
+ opts?.idempotencyKey,
284
+ opts
285
+ );
140
286
  }
141
- transfer(id, destination, summary) {
142
- return this.http.request("POST", `/api/calls/${id}/transfer`, { destination, summary });
287
+ /**
288
+ * Poll a call until it reaches a terminal status (`completed`,
289
+ * `failed`, `ended`, `cancelled`) and return the final record.
290
+ *
291
+ * Saves consumers from writing the same loop in every script. Use
292
+ * webhooks instead in production — this is fine for scripts, demos,
293
+ * and one-off jobs but consumes API quota on every poll.
294
+ *
295
+ * @param opts.pollIntervalMs - default 5s
296
+ * @param opts.timeoutMs - default 30min (rejects with
297
+ * `OsmtalkError` 408 on timeout)
298
+ * @param opts.signal - abort externally
299
+ */
300
+ async waitUntilEnded(id, opts) {
301
+ const pollInterval = Math.max(1e3, opts?.pollIntervalMs ?? 5e3);
302
+ const totalTimeout = opts?.timeoutMs ?? 30 * 60 * 1e3;
303
+ const deadline = Date.now() + totalTimeout;
304
+ const terminal = new Set(TERMINAL_CALL_STATUSES);
305
+ while (true) {
306
+ if (opts?.signal?.aborted) {
307
+ throw new OsmtalkError(0, { error: "Aborted by caller" });
308
+ }
309
+ const call = await this.get(id, { signal: opts?.signal });
310
+ if (terminal.has(call.status)) return call;
311
+ if (Date.now() >= deadline) {
312
+ throw new OsmtalkError(408, {
313
+ error: `waitUntilEnded: call ${id} did not reach a terminal state within ${totalTimeout}ms (last status: ${call.status})`
314
+ });
315
+ }
316
+ await sleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())));
317
+ }
143
318
  }
144
319
  };
145
320
  var PlatformResource = class {
@@ -416,6 +591,8 @@ var index_default = Osmtalk;
416
591
  export {
417
592
  Osmtalk,
418
593
  OsmtalkError,
594
+ SDK_VERSION,
595
+ TERMINAL_CALL_STATUSES,
419
596
  index_default as default,
420
597
  verifyWebhookSignature,
421
598
  verifyWebhookSignatureAsync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osmapi/osmtalk-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official TypeScript SDK for the osmTalk voice AI platform",
5
5
  "homepage": "https://docs.osmtalk.com",
6
6
  "repository": {