@skailar-ai/sdk 0.0.3 → 0.0.4

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.cjs CHANGED
@@ -27,7 +27,14 @@ var SkailarError = class extends Error {
27
27
  status;
28
28
  /** Machine-readable error code from the body, if any. */
29
29
  code;
30
- /** Correlation id from the `x-request-id` response header, if any. */
30
+ /**
31
+ * Correlation id from the `x-request-id` response header, if any, for support.
32
+ *
33
+ * @remarks
34
+ * Populated on error responses. It is **not** currently surfaced for successful
35
+ * streaming or audio responses (which return a stream rather than a wrapper),
36
+ * so capture it from a thrown {@link SkailarError} when filing a ticket.
37
+ */
31
38
  requestId;
32
39
  /** The raw response body captured for debugging. */
33
40
  raw;
@@ -138,6 +145,25 @@ async function* parseSSE(stream, signal) {
138
145
  const reader = stream.getReader();
139
146
  const decoder = new TextDecoder();
140
147
  let buffer = "";
148
+ function nextLine() {
149
+ let i = -1;
150
+ for (let j = 0; j < buffer.length; j++) {
151
+ const c = buffer[j];
152
+ if (c === "\n" || c === "\r") {
153
+ i = j;
154
+ break;
155
+ }
156
+ }
157
+ if (i === -1) return null;
158
+ const line = buffer.slice(0, i);
159
+ if (buffer[i] === "\r") {
160
+ if (i === buffer.length - 1) return null;
161
+ buffer = buffer.slice(i + (buffer[i + 1] === "\n" ? 2 : 1));
162
+ } else {
163
+ buffer = buffer.slice(i + 1);
164
+ }
165
+ return line;
166
+ }
141
167
  try {
142
168
  while (true) {
143
169
  if (signal?.aborted) {
@@ -146,11 +172,8 @@ async function* parseSSE(stream, signal) {
146
172
  const { done, value } = await reader.read();
147
173
  if (done) break;
148
174
  buffer += decoder.decode(value, { stream: true });
149
- let newlineIndex;
150
- while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
151
- const rawLine = buffer.slice(0, newlineIndex);
152
- buffer = buffer.slice(newlineIndex + 1);
153
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
175
+ let line;
176
+ while ((line = nextLine()) !== null) {
154
177
  if (line === "" || line.startsWith(":")) continue;
155
178
  if (!line.startsWith("data:")) continue;
156
179
  const data = line.slice(5).trimStart();
@@ -158,7 +181,7 @@ async function* parseSSE(stream, signal) {
158
181
  yield data;
159
182
  }
160
183
  }
161
- const tail = buffer.trim();
184
+ const tail = buffer.replace(/[\r\n]+$/, "");
162
185
  if (tail.startsWith("data:")) {
163
186
  const data = tail.slice(5).trimStart();
164
187
  if (data !== "[DONE]" && data !== "") yield data;
@@ -194,12 +217,15 @@ var ChatCompletionStream = class {
194
217
  *
195
218
  * Each `data:` payload is `JSON.parse`d. If a payload carries an `error` field
196
219
  * (the gateway's in-band failure signal), iteration throws the corresponding
197
- * {@link SkailarAPIError} instead of yielding. Malformed JSON payloads are
198
- * skipped defensively.
220
+ * {@link SkailarAPIError} instead of yielding. A payload that is not valid JSON
221
+ * (e.g. a plain-text error injected by an intermediary) throws a
222
+ * {@link SkailarConnectionError} rather than being dropped, so a malformed
223
+ * stream cannot truncate the response silently.
199
224
  *
200
225
  * @returns An async generator over {@link ChatCompletionChunk} values.
201
226
  * @throws {@link SkailarAPIError} When the stream delivers an in-band error event.
202
- * @throws {@link SkailarConnectionError} When the stream is aborted or read fails.
227
+ * @throws {@link SkailarConnectionError} When a payload is malformed, or the
228
+ * stream is aborted or fails to read.
203
229
  */
204
230
  async *decode() {
205
231
  for await (const data of parseSSE(this.body, this.controller.signal)) {
@@ -207,7 +233,9 @@ var ChatCompletionStream = class {
207
233
  try {
208
234
  parsed = JSON.parse(data);
209
235
  } catch {
210
- continue;
236
+ throw new SkailarConnectionError({
237
+ message: `Malformed streaming event (not JSON): ${data.slice(0, 200)}`
238
+ });
211
239
  }
212
240
  if (parsed !== null && typeof parsed === "object" && "error" in parsed) {
213
241
  const { code, message } = parseErrorBody(parsed);
@@ -274,6 +302,8 @@ var ChatCompletions = class {
274
302
  path: "/v1/chat/completions",
275
303
  body,
276
304
  expect: "stream",
305
+ // Text generation is safe to replay before the stream starts.
306
+ idempotent: true,
277
307
  signal: options?.signal,
278
308
  timeout: options?.timeout,
279
309
  headers: options?.headers
@@ -284,6 +314,8 @@ var ChatCompletions = class {
284
314
  path: "/v1/chat/completions",
285
315
  body,
286
316
  expect: "json",
317
+ // Text generation is safe to replay.
318
+ idempotent: true,
287
319
  signal: options?.signal,
288
320
  timeout: options?.timeout,
289
321
  headers: options?.headers
@@ -319,6 +351,7 @@ var ModelsResource = class {
319
351
  method: "GET",
320
352
  path: "/v1/models",
321
353
  expect: "json",
354
+ idempotent: true,
322
355
  signal: options?.signal,
323
356
  timeout: options?.timeout,
324
357
  headers: options?.headers
@@ -340,6 +373,7 @@ var ModelsResource = class {
340
373
  method: "GET",
341
374
  path: `/v1/models/${encoded}`,
342
375
  expect: "json",
376
+ idempotent: true,
343
377
  signal: options?.signal,
344
378
  timeout: options?.timeout,
345
379
  headers: options?.headers
@@ -372,6 +406,8 @@ var ImagesResource = class {
372
406
  path: "/v1/images/generations",
373
407
  body,
374
408
  expect: "json",
409
+ // Billed image generation: never replay on 5xx/timeout to avoid double charges.
410
+ idempotent: false,
375
411
  signal: options?.signal,
376
412
  timeout: options?.timeout,
377
413
  headers: options?.headers
@@ -432,6 +468,8 @@ var AudioTranscriptions = class {
432
468
  path: "/v1/audio/transcriptions",
433
469
  body: { base64, mime },
434
470
  expect: "json",
471
+ // Billed transcription: never replay on 5xx/timeout to avoid double charges.
472
+ idempotent: false,
435
473
  signal: options?.signal,
436
474
  timeout: options?.timeout,
437
475
  headers: options?.headers
@@ -469,6 +507,8 @@ var AudioSpeech = class {
469
507
  body: { input: params.input, voice: params.voice },
470
508
  headers: { Accept: "audio/mpeg", ...options?.headers },
471
509
  expect: "response",
510
+ // Billed speech synthesis: never replay on 5xx/timeout to avoid double charges.
511
+ idempotent: false,
472
512
  signal: options?.signal,
473
513
  timeout: options?.timeout
474
514
  });
@@ -513,7 +553,9 @@ var ImageUploads = class {
513
553
  method: "POST",
514
554
  path: "/v1/uploads/images",
515
555
  body: { base64, content_type: params.contentType },
516
- expect: "json"
556
+ expect: "json",
557
+ // Upload creates a stored asset: never replay to avoid duplicate uploads.
558
+ idempotent: false
517
559
  });
518
560
  }
519
561
  };
@@ -537,7 +579,9 @@ var FileUploads = class {
537
579
  method: "POST",
538
580
  path: "/v1/uploads/files",
539
581
  body: { base64, content_type: params.contentType },
540
- expect: "json"
582
+ expect: "json",
583
+ // Upload creates a stored asset: never replay to avoid duplicate uploads.
584
+ idempotent: false
541
585
  });
542
586
  }
543
587
  };
@@ -554,6 +598,11 @@ var UploadsResource = class {
554
598
  };
555
599
 
556
600
  // src/client.ts
601
+ var MAX_RETRY_DELAY_MS = 6e4;
602
+ function capRetryDelay(retryAfterSeconds) {
603
+ if (retryAfterSeconds === void 0) return void 0;
604
+ return Math.min(Math.max(0, retryAfterSeconds) * 1e3, MAX_RETRY_DELAY_MS);
605
+ }
557
606
  function delay(ms, signal) {
558
607
  return new Promise((resolve, reject) => {
559
608
  if (signal?.aborted) {
@@ -675,6 +724,7 @@ var Skailar = class {
675
724
  method: "GET",
676
725
  path: "/v1/ping-key",
677
726
  expect: "json",
727
+ idempotent: true,
678
728
  signal: options?.signal,
679
729
  timeout: options?.timeout,
680
730
  headers: options?.headers
@@ -685,15 +735,22 @@ var Skailar = class {
685
735
  *
686
736
  * Applies, per attempt: header assembly with bearer auth, a timeout-derived
687
737
  * {@link AbortSignal} composed with any caller signal, execution of
688
- * {@link SkailarOptions.fetch}, and error mapping. Retries HTTP 429, HTTP 5xx
689
- * and transient connection failures up to {@link Skailar.maxRetries}, backing
690
- * off with full-jitter exponential delay and honoring a server `Retry-After`
691
- * when present. Non-429 4xx responses fail fast.
738
+ * {@link SkailarOptions.fetch}, and error mapping. Backs off with full-jitter
739
+ * exponential delay, honoring a server `Retry-After` when present (capped at
740
+ * {@link MAX_RETRY_DELAY_MS} so an oversized value cannot stall the call), up
741
+ * to {@link Skailar.maxRetries}. Non-429 4xx responses fail fast.
742
+ *
743
+ * Retries are scoped to avoid duplicating side effects: an HTTP `429` is always
744
+ * retryable (rejected before execution), while a `5xx`, timeout or connection
745
+ * failure is retried **only** when `options.idempotent` is set — because the
746
+ * request may already have executed server-side. Requests with billed side
747
+ * effects (image generation, speech, transcription, uploads) leave it unset and
748
+ * are therefore never replayed once they may have reached the gateway.
692
749
  *
693
750
  * Transport failures are reported as {@link SkailarConnectionError} with a
694
751
  * message distinguishing three causes: an external `signal` abort
695
- * (non-retryable), an internal timeout once {@link Skailar.timeout} elapses
696
- * (retryable), and a generic network failure (retryable).
752
+ * (non-retryable), an internal timeout once {@link Skailar.timeout} elapses,
753
+ * and a generic network failure.
697
754
  *
698
755
  * @param options - The request description.
699
756
  * @returns The parsed JSON, raw `Response`, or {@link ChatCompletionStream}
@@ -703,6 +760,7 @@ var Skailar = class {
703
760
  const url = `${this.baseURL}${options.path}`;
704
761
  const isStream = options.expect === "stream";
705
762
  const timeoutMs = options.timeout ?? this.timeout;
763
+ const idempotent = options.idempotent ?? false;
706
764
  let attempt = 0;
707
765
  while (true) {
708
766
  const controller = new AbortController();
@@ -733,7 +791,7 @@ var Skailar = class {
733
791
  message: externallyAborted ? "Request aborted" : timedOut ? `Request timed out after ${timeoutMs}ms` : "Network request to the Skailar API failed",
734
792
  cause: err
735
793
  });
736
- if (externallyAborted || !this.shouldRetry(attempt)) throw connErr;
794
+ if (externallyAborted || !idempotent || !this.shouldRetry(attempt)) throw connErr;
737
795
  attempt += 1;
738
796
  await delay(this.backoff(attempt), options.signal);
739
797
  continue;
@@ -742,8 +800,8 @@ var Skailar = class {
742
800
  if (!response.ok) {
743
801
  detachExternal();
744
802
  const apiError = await this.toApiError(response);
745
- const retryAfterMs = apiError instanceof SkailarRateLimitError && apiError.retryAfter !== void 0 ? apiError.retryAfter * 1e3 : void 0;
746
- if (this.isRetryableStatus(response.status) && this.shouldRetry(attempt)) {
803
+ const retryAfterMs = apiError instanceof SkailarRateLimitError ? capRetryDelay(apiError.retryAfter) : void 0;
804
+ if (this.isRetryableStatus(response.status, idempotent) && this.shouldRetry(attempt)) {
747
805
  attempt += 1;
748
806
  await delay(retryAfterMs ?? this.backoff(attempt), options.signal);
749
807
  continue;
@@ -770,8 +828,14 @@ var Skailar = class {
770
828
  }
771
829
  }
772
830
  /**
773
- * Assemble the outgoing header set for a request, applying defaults, auth,
774
- * content-type and accept in precedence order.
831
+ * Assemble the outgoing header set for a request.
832
+ *
833
+ * Precedence (later wins): client `defaultHeaders` → the SDK's default `Accept`
834
+ * and `Content-Type` → per-call `options.headers` → `Authorization`.
835
+ * `Authorization` is applied **last** so a caller-supplied header (including a
836
+ * blindly proxied incoming one) can never override or drop the bearer token and
837
+ * leak it elsewhere. `Accept`/`Content-Type` remain overridable on purpose —
838
+ * e.g. audio speech sends `Accept: audio/mpeg`.
775
839
  *
776
840
  * @param options - The request description.
777
841
  * @returns The header record to send.
@@ -779,11 +843,14 @@ var Skailar = class {
779
843
  buildHeaders(options) {
780
844
  const headers = {
781
845
  ...this.defaultHeaders,
782
- Authorization: `Bearer ${this.apiKey}`,
783
846
  Accept: options.expect === "stream" ? "text/event-stream" : "application/json"
784
847
  };
785
848
  if (options.body !== void 0) headers["Content-Type"] = "application/json";
786
849
  if (options.headers) Object.assign(headers, options.headers);
850
+ for (const key of Object.keys(headers)) {
851
+ if (key.toLowerCase() === "authorization") delete headers[key];
852
+ }
853
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
787
854
  return headers;
788
855
  }
789
856
  /**
@@ -809,13 +876,20 @@ var Skailar = class {
809
876
  return SkailarAPIError.from(response.status, parsed, requestId, raw, retryAfter);
810
877
  }
811
878
  /**
812
- * Whether a status code is eligible for automatic retry (429 and any 5xx).
879
+ * Whether an HTTP error status is eligible for automatic retry.
880
+ *
881
+ * A `429` is always retryable: the gateway rejects it before executing the
882
+ * request, so replaying it cannot duplicate side effects. A `5xx` may mean the
883
+ * request already executed server-side, so it is retried only when the caller
884
+ * marked the request idempotent.
813
885
  *
814
886
  * @param status - The HTTP status code.
887
+ * @param idempotent - Whether replaying the request is safe.
815
888
  * @returns `true` if retryable.
816
889
  */
817
- isRetryableStatus(status) {
818
- return status === 429 || status >= 500;
890
+ isRetryableStatus(status, idempotent) {
891
+ if (status === 429) return true;
892
+ return status >= 500 && idempotent;
819
893
  }
820
894
  /**
821
895
  * Whether another attempt remains within the retry budget.