kugelaudio 0.2.3 → 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/src/errors.ts CHANGED
@@ -1,73 +1,321 @@
1
1
  /**
2
2
  * Custom errors for KugelAudio SDK.
3
+ *
4
+ * All SDK errors inherit from {@link KugelAudioError}. Specific subclasses
5
+ * map to the server's `error_code` field (see the server-side `ErrorCode`
6
+ * enum at `tts/src/serving/deployments/errors.py`) so callers can
7
+ * `instanceof AuthenticationError` without matching on message text.
3
8
  */
4
9
 
10
+ // Keep in lockstep with the server `ErrorCode` enum.
11
+ export const ErrorCodes = {
12
+ UNAUTHORIZED: 'UNAUTHORIZED',
13
+ RATE_LIMITED: 'RATE_LIMITED',
14
+ INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
15
+ MODEL_UNAVAILABLE: 'MODEL_UNAVAILABLE',
16
+ EMPTY_AUDIO: 'EMPTY_AUDIO',
17
+ VALIDATION: 'VALIDATION_ERROR',
18
+ INTERNAL: 'INTERNAL_ERROR',
19
+ NOT_FOUND: 'NOT_FOUND',
20
+ } as const;
21
+ export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
22
+
23
+ // Server-defined WebSocket close codes.
24
+ export const WsCloseCodes = {
25
+ UNAUTHORIZED: 4001,
26
+ INSUFFICIENT_CREDITS: 4003,
27
+ RATE_LIMITED: 4029,
28
+ MODEL_UNAVAILABLE: 4500,
29
+ } as const;
30
+
31
+ const API_KEYS_URL = 'https://app.kugelaudio.com/settings/api-keys';
32
+ const BILLING_URL = 'https://app.kugelaudio.com/billing';
33
+
34
+ export interface KugelAudioErrorOptions {
35
+ statusCode?: number;
36
+ errorCode?: string;
37
+ requestId?: string;
38
+ retryAfter?: number;
39
+ cause?: unknown;
40
+ }
41
+
5
42
  /**
6
43
  * Base error class for KugelAudio SDK.
7
44
  */
8
45
  export class KugelAudioError extends Error {
9
46
  public readonly statusCode?: number;
47
+ public readonly errorCode?: string;
48
+ public readonly requestId?: string;
49
+ public readonly retryAfter?: number;
10
50
 
11
- constructor(message: string, statusCode?: number) {
12
- super(message);
51
+ constructor(message: string, options: KugelAudioErrorOptions = {}) {
52
+ super(options.requestId ? `${message} (request_id: ${options.requestId})` : message);
13
53
  this.name = 'KugelAudioError';
14
- this.statusCode = statusCode;
54
+ this.statusCode = options.statusCode;
55
+ this.errorCode = options.errorCode;
56
+ this.requestId = options.requestId;
57
+ this.retryAfter = options.retryAfter;
15
58
  Object.setPrototypeOf(this, KugelAudioError.prototype);
16
59
  }
17
60
  }
18
61
 
19
62
  /**
20
- * Thrown when authentication fails.
63
+ * API key was missing, malformed, or rejected by the server.
21
64
  */
22
65
  export class AuthenticationError extends KugelAudioError {
23
- constructor(message: string = 'Authentication failed') {
24
- super(message, 401);
66
+ constructor(message?: string, options: KugelAudioErrorOptions = {}) {
67
+ super(
68
+ message ?? `KugelAudio rejected the API key. Check it is current at ${API_KEYS_URL}.`,
69
+ { statusCode: 401, errorCode: ErrorCodes.UNAUTHORIZED, ...options },
70
+ );
25
71
  this.name = 'AuthenticationError';
26
72
  Object.setPrototypeOf(this, AuthenticationError.prototype);
27
73
  }
28
74
  }
29
75
 
30
76
  /**
31
- * Thrown when rate limit is exceeded.
77
+ * Request was rejected by the per-org rate limiter.
32
78
  */
33
79
  export class RateLimitError extends KugelAudioError {
34
- constructor(message: string = 'Rate limit exceeded') {
35
- super(message, 429);
80
+ constructor(message?: string, options: KugelAudioErrorOptions = {}) {
81
+ const msg =
82
+ message ??
83
+ (options.retryAfter
84
+ ? `KugelAudio rate limit hit; retry after ${options.retryAfter}s.`
85
+ : 'KugelAudio rate limit hit; retry shortly.');
86
+ super(msg, { statusCode: 429, errorCode: ErrorCodes.RATE_LIMITED, ...options });
36
87
  this.name = 'RateLimitError';
37
88
  Object.setPrototypeOf(this, RateLimitError.prototype);
38
89
  }
39
90
  }
40
91
 
41
92
  /**
42
- * Thrown when user has insufficient credits.
93
+ * Account is out of TTS credits.
43
94
  */
44
95
  export class InsufficientCreditsError extends KugelAudioError {
45
- constructor(message: string = 'Insufficient credits') {
46
- super(message, 403);
96
+ constructor(message?: string, options: KugelAudioErrorOptions = {}) {
97
+ super(
98
+ message ?? `Your KugelAudio account is out of credits. Top up at ${BILLING_URL}.`,
99
+ { statusCode: 402, errorCode: ErrorCodes.INSUFFICIENT_CREDITS, ...options },
100
+ );
47
101
  this.name = 'InsufficientCreditsError';
48
102
  Object.setPrototypeOf(this, InsufficientCreditsError.prototype);
49
103
  }
50
104
  }
51
105
 
52
106
  /**
53
- * Thrown when request validation fails.
107
+ * Request was rejected as invalid (bad params, missing fields, etc.).
54
108
  */
55
109
  export class ValidationError extends KugelAudioError {
56
- constructor(message: string) {
57
- super(message, 400);
110
+ constructor(message: string, options: KugelAudioErrorOptions = {}) {
111
+ super(message, { statusCode: 400, errorCode: ErrorCodes.VALIDATION, ...options });
58
112
  this.name = 'ValidationError';
59
113
  Object.setPrototypeOf(this, ValidationError.prototype);
60
114
  }
61
115
  }
62
116
 
63
117
  /**
64
- * Thrown when connection to server fails.
118
+ * The SDK could not reach KugelAudio (network error, server down,
119
+ * or model deployment temporarily unavailable).
65
120
  */
66
121
  export class ConnectionError extends KugelAudioError {
67
- constructor(message: string = 'Failed to connect to server') {
68
- super(message, 503);
122
+ constructor(message: string, options: KugelAudioErrorOptions = {}) {
123
+ super(message, { statusCode: 503, ...options });
69
124
  this.name = 'ConnectionError';
70
125
  Object.setPrototypeOf(this, ConnectionError.prototype);
71
126
  }
72
127
  }
73
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // Classifiers
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function build(
134
+ status: number | undefined,
135
+ errorCode: string | undefined,
136
+ message: string,
137
+ opts: { requestId?: string; retryAfter?: number; cause?: unknown } = {},
138
+ ): KugelAudioError {
139
+ // Only include keys whose value we actually know. Subclass constructors
140
+ // spread `...options` over their canonical defaults (e.g. statusCode: 401),
141
+ // so a key with `undefined` would clobber the default.
142
+ const common: {
143
+ statusCode?: number;
144
+ errorCode?: string;
145
+ requestId?: string;
146
+ retryAfter?: number;
147
+ cause?: unknown;
148
+ } = { ...opts };
149
+ if (status !== undefined) common.statusCode = status;
150
+ if (errorCode !== undefined) common.errorCode = errorCode;
151
+
152
+ if (errorCode === ErrorCodes.UNAUTHORIZED || status === 401) {
153
+ return new AuthenticationError(message || undefined, common);
154
+ }
155
+ if (errorCode === ErrorCodes.INSUFFICIENT_CREDITS || status === 402) {
156
+ return new InsufficientCreditsError(message || undefined, common);
157
+ }
158
+ if (errorCode === ErrorCodes.RATE_LIMITED || status === 429) {
159
+ return new RateLimitError(message || undefined, common);
160
+ }
161
+ if (errorCode === ErrorCodes.VALIDATION || status === 400) {
162
+ return new ValidationError(message || 'Request validation failed.', common);
163
+ }
164
+ if (errorCode === ErrorCodes.MODEL_UNAVAILABLE || status === 503) {
165
+ const detail = message || 'service temporarily unavailable';
166
+ return new ConnectionError(
167
+ `KugelAudio is temporarily unavailable: ${detail}. Retry shortly.`,
168
+ common,
169
+ );
170
+ }
171
+ return new KugelAudioError(message || `HTTP ${status}`, common);
172
+ }
173
+
174
+ interface HttpResponseLike {
175
+ status: number;
176
+ headers: { get(name: string): string | null } | Record<string, string | undefined>;
177
+ text?: () => Promise<string>;
178
+ }
179
+
180
+ function readHeader(
181
+ headers: HttpResponseLike['headers'],
182
+ name: string,
183
+ ): string | undefined {
184
+ if (headers && typeof (headers as Headers).get === 'function') {
185
+ return (headers as Headers).get(name) ?? undefined;
186
+ }
187
+ const rec = headers as Record<string, string | undefined>;
188
+ return rec[name] ?? rec[name.toLowerCase()] ?? undefined;
189
+ }
190
+
191
+ /**
192
+ * Build the appropriate `KugelAudioError` from an HTTP response body that
193
+ * was already parsed. `bodyText` is the raw text fallback.
194
+ */
195
+ export function classifyHttpError(
196
+ status: number,
197
+ bodyText: string,
198
+ headers: HttpResponseLike['headers'],
199
+ ): KugelAudioError {
200
+ let errorCode: string | undefined;
201
+ let message = '';
202
+ let retryAfter: number | undefined;
203
+
204
+ if (bodyText) {
205
+ try {
206
+ const body = JSON.parse(bodyText);
207
+ if (body && typeof body === 'object') {
208
+ errorCode = typeof body.error_code === 'string' ? body.error_code : undefined;
209
+ const msg = body.error ?? body.detail;
210
+ if (Array.isArray(msg)) {
211
+ message = msg.map((m) => String(m)).join('; ');
212
+ } else if (typeof msg === 'string') {
213
+ message = msg;
214
+ }
215
+ if (typeof body.retry_after === 'number') {
216
+ retryAfter = body.retry_after;
217
+ }
218
+ }
219
+ } catch {
220
+ // Not JSON; fall back to raw body text below.
221
+ }
222
+ }
223
+
224
+ if (retryAfter === undefined) {
225
+ const header = readHeader(headers, 'Retry-After') ?? readHeader(headers, 'retry-after');
226
+ if (header) {
227
+ const n = Number(header);
228
+ if (Number.isFinite(n)) retryAfter = n;
229
+ }
230
+ }
231
+
232
+ const requestId = readHeader(headers, 'x-request-id') ?? readHeader(headers, 'X-Request-Id');
233
+
234
+ if (!message) {
235
+ message = (bodyText || '').trim();
236
+ }
237
+
238
+ return build(status, errorCode, message, { requestId, retryAfter });
239
+ }
240
+
241
+ /**
242
+ * Build a `KugelAudioError` from a server-sent WebSocket error frame
243
+ * (`{error, error_code, retry_after}`).
244
+ */
245
+ export function classifyWsFrame(data: {
246
+ error?: string;
247
+ error_code?: string;
248
+ retry_after?: number;
249
+ }): KugelAudioError {
250
+ const errorCode = data.error_code;
251
+ const message = data.error ?? 'Server reported an error.';
252
+ const retryAfter = typeof data.retry_after === 'number' ? data.retry_after : undefined;
253
+ return build(undefined, errorCode, message, { retryAfter });
254
+ }
255
+
256
+ /**
257
+ * Build a `KugelAudioError` from a WebSocket close code + reason.
258
+ */
259
+ export function classifyWsClose(
260
+ code: number | undefined,
261
+ reason?: string,
262
+ ): KugelAudioError {
263
+ const reasonTxt = (reason ?? '').trim();
264
+
265
+ if (code === WsCloseCodes.UNAUTHORIZED) {
266
+ let msg = `KugelAudio rejected the API key. Check it is current at ${API_KEYS_URL}.`;
267
+ if (reasonTxt) msg = `${msg} (${reasonTxt})`;
268
+ return new AuthenticationError(msg);
269
+ }
270
+ if (code === WsCloseCodes.INSUFFICIENT_CREDITS) {
271
+ return new InsufficientCreditsError();
272
+ }
273
+ if (code === WsCloseCodes.RATE_LIMITED) {
274
+ return new RateLimitError();
275
+ }
276
+ if (code === WsCloseCodes.MODEL_UNAVAILABLE) {
277
+ const suffix = reasonTxt ? ` (${reasonTxt})` : '';
278
+ return new ConnectionError(
279
+ `KugelAudio model is temporarily unavailable. Retry shortly.${suffix}`,
280
+ );
281
+ }
282
+
283
+ const detail = reasonTxt || 'no reason given';
284
+ const codeStr = code !== undefined ? ` (code ${code})` : '';
285
+ return new ConnectionError(
286
+ `KugelAudio WebSocket closed by server: ${detail}${codeStr}.`,
287
+ );
288
+ }
289
+
290
+ /**
291
+ * Extract the HTTP status from a `ws` package handshake-rejection error and
292
+ * return a typed `KugelAudioError`. Returns `null` if the error doesn't look
293
+ * like a handshake rejection (e.g. pure network failure).
294
+ *
295
+ * The `ws` library surfaces rejected upgrades via:
296
+ * - an Error whose `.message` is `"Unexpected server response: <status>"`
297
+ * - `error.code === 'EUNEXPECTEDRESPONSE'`, with `error.statusCode` on some versions
298
+ *
299
+ * The TTS server rejects WS upgrades with a bare API key using HTTP 403
300
+ * (not 401), so we treat 403 here as an auth failure — HTTP API callers
301
+ * keep the generic 403 semantics via {@link classifyHttpError}.
302
+ */
303
+ export function classifyWsHandshakeError(err: unknown): KugelAudioError | null {
304
+ if (!err || typeof err !== 'object') return null;
305
+ const e = err as { message?: unknown; statusCode?: unknown; code?: unknown };
306
+
307
+ let status: number | undefined;
308
+ if (typeof e.statusCode === 'number') {
309
+ status = e.statusCode;
310
+ }
311
+ if (status === undefined && typeof e.message === 'string') {
312
+ const m = e.message.match(/Unexpected server response:\s*(\d{3})/i);
313
+ if (m) status = Number(m[1]);
314
+ }
315
+ if (status === undefined) return null;
316
+
317
+ if (status === 403) {
318
+ return new AuthenticationError();
319
+ }
320
+ return build(status, undefined, typeof e.message === 'string' ? e.message : '');
321
+ }
package/src/index.ts CHANGED
@@ -39,7 +39,7 @@
39
39
  * @packageDocumentation
40
40
  */
41
41
 
42
- // Main client
42
+ // Main client and session classes
43
43
  export { KugelAudio } from './client';
44
44
 
45
45
  // Types
@@ -47,18 +47,26 @@ export type {
47
47
  AudioChunk,
48
48
  AudioResponse,
49
49
  ContextVoiceSettings,
50
+ CreateVoiceOptions,
50
51
  GenerateOptions,
51
52
  GenerationStats,
52
53
  KugelAudioOptions,
53
54
  Model,
55
+ Region,
54
56
  MultiContextAudioChunk,
55
57
  MultiContextCallbacks,
56
58
  MultiContextConfig,
57
59
  StreamCallbacks,
58
60
  StreamConfig,
61
+ StreamingSessionCallbacks,
62
+ UpdateVoiceOptions,
59
63
  Voice,
60
64
  VoiceAge,
61
65
  VoiceCategory,
66
+ VoiceDetail,
67
+ VoiceListResponse,
68
+ VoiceQuality,
69
+ VoiceReference,
62
70
  VoiceSex,
63
71
  WordTimestamp
64
72
  } from './types';
@@ -67,11 +75,18 @@ export type {
67
75
  export {
68
76
  AuthenticationError,
69
77
  ConnectionError,
78
+ ErrorCodes,
70
79
  InsufficientCreditsError,
71
80
  KugelAudioError,
72
81
  RateLimitError,
73
- ValidationError
82
+ ValidationError,
83
+ WsCloseCodes,
84
+ classifyHttpError,
85
+ classifyWsClose,
86
+ classifyWsFrame,
87
+ classifyWsHandshakeError,
74
88
  } from './errors';
89
+ export type { ErrorCode, KugelAudioErrorOptions } from './errors';
75
90
 
76
91
  // Utilities
77
92
  export {