@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.d.ts CHANGED
@@ -309,11 +309,11 @@ interface ChatCompletionChunk {
309
309
  * arrival order, excluding the terminal `[DONE]` sentinel.
310
310
  *
311
311
  * Implements just the slice of the SSE grammar the gateway emits: UTF-8 `data:`
312
- * lines separated by `\n`, events delimited by blank lines, terminated by a
313
- * literal `data: [DONE]`. A partial line left in the buffer across reads is
314
- * preserved and completed by the next chunk. Comment lines (`:` prefix) and
315
- * non-`data:` fields are ignored. Yielding stops at `[DONE]`; reaching
316
- * end-of-stream without `[DONE]` simply ends the generator.
312
+ * lines terminated by any of `\n`, `\r\n` or `\r` (per the SSE spec), events
313
+ * delimited by blank lines, terminated by a literal `data: [DONE]`. A partial
314
+ * line left in the buffer across reads is preserved and completed by the next
315
+ * chunk. Comment lines (`:` prefix) and non-`data:` fields are ignored. Yielding
316
+ * stops at `[DONE]`; reaching end-of-stream without `[DONE]` simply ends the generator.
317
317
  *
318
318
  * On any exit — normal completion, `[DONE]`, an error, or the consumer
319
319
  * abandoning the `for await` early — the reader is cancelled before its lock is
@@ -365,12 +365,15 @@ declare class ChatCompletionStream implements AsyncIterable<ChatCompletionChunk>
365
365
  *
366
366
  * Each `data:` payload is `JSON.parse`d. If a payload carries an `error` field
367
367
  * (the gateway's in-band failure signal), iteration throws the corresponding
368
- * {@link SkailarAPIError} instead of yielding. Malformed JSON payloads are
369
- * skipped defensively.
368
+ * {@link SkailarAPIError} instead of yielding. A payload that is not valid JSON
369
+ * (e.g. a plain-text error injected by an intermediary) throws a
370
+ * {@link SkailarConnectionError} rather than being dropped, so a malformed
371
+ * stream cannot truncate the response silently.
370
372
  *
371
373
  * @returns An async generator over {@link ChatCompletionChunk} values.
372
374
  * @throws {@link SkailarAPIError} When the stream delivers an in-band error event.
373
- * @throws {@link SkailarConnectionError} When the stream is aborted or read fails.
375
+ * @throws {@link SkailarConnectionError} When a payload is malformed, or the
376
+ * stream is aborted or fails to read.
374
377
  */
375
378
  private decode;
376
379
  /**
@@ -890,7 +893,11 @@ interface SkailarOptions {
890
893
  * The format is **not** validated locally — only emptiness is checked. A
891
894
  * malformed or wrong-provider key (e.g. an OpenAI `sk-...` key passed by
892
895
  * mistake) is accepted here and rejected at the first request with a
893
- * {@link SkailarAuthError} (HTTP 401).
896
+ * {@link SkailarAuthError} (HTTP 401). Note this means such a key is **sent to
897
+ * {@link SkailarOptions.baseURL}** before it is rejected; if you point
898
+ * `baseURL` at a third-party host, take care not to forward a credential meant
899
+ * for a different provider. Keep the default `baseURL` unless you control the
900
+ * endpoint.
894
901
  */
895
902
  apiKey?: string;
896
903
  /**
@@ -900,9 +907,18 @@ interface SkailarOptions {
900
907
  */
901
908
  baseURL?: string;
902
909
  /**
903
- * Per-request timeout in milliseconds (default `60000`). Applies to each
904
- * attempt independently; a timed-out attempt becomes a
905
- * {@link SkailarConnectionError} and is eligible for retry.
910
+ * Timeout in milliseconds applied to **each attempt** (default `60000`). A
911
+ * timed-out attempt becomes a {@link SkailarConnectionError} and, for
912
+ * idempotent requests, is eligible for retry.
913
+ *
914
+ * @remarks
915
+ * This is **per attempt, not a total deadline**. With retries enabled the
916
+ * worst-case wall-clock for one call is roughly
917
+ * `(maxRetries + 1) × timeout + backoff` — e.g. the defaults (60s, 2 retries)
918
+ * can keep a call pending for ~3 minutes on a persistently slow endpoint. In
919
+ * environments with their own hard limits (serverless/Lambda, HTTP handlers),
920
+ * enforce an end-to-end bound by passing an `AbortSignal` (and/or a smaller
921
+ * per-call `timeout`) in the request options, or set `maxRetries: 0`.
906
922
  */
907
923
  timeout?: number;
908
924
  /**
@@ -918,8 +934,9 @@ interface SkailarOptions {
918
934
  */
919
935
  fetch?: typeof fetch;
920
936
  /**
921
- * Headers merged into every request. The SDK's own `Authorization`,
922
- * `Content-Type` and `Accept`, plus explicit per-call headers, take precedence.
937
+ * Headers merged into every request. Per-call `options.headers` override these.
938
+ * The SDK's `Authorization` always wins and cannot be overridden; `Accept` and
939
+ * `Content-Type` default to SDK values but may be overridden here or per call.
923
940
  */
924
941
  defaultHeaders?: Record<string, string>;
925
942
  }
@@ -942,7 +959,11 @@ interface RequestOptions {
942
959
  * for this request only.
943
960
  */
944
961
  timeout?: number;
945
- /** Extra headers merged into this request, overriding client defaults. */
962
+ /**
963
+ * Extra headers merged into this request, overriding client `defaultHeaders`.
964
+ * Cannot override `Authorization` (always set by the SDK); may override `Accept`
965
+ * and `Content-Type`.
966
+ */
946
967
  headers?: Record<string, string>;
947
968
  }
948
969
  /** Internal description of a single HTTP request to dispatch. */
@@ -963,6 +984,15 @@ interface InternalRequest {
963
984
  signal?: AbortSignal;
964
985
  /** Per-call timeout override in ms; falls back to {@link Skailar.timeout}. */
965
986
  timeout?: number;
987
+ /**
988
+ * Whether the request is safe to replay after a 5xx, timeout or connection
989
+ * failure — i.e. the server either did not execute it or executing it twice is
990
+ * harmless. `GET`s and text completions are idempotent; operations with billed
991
+ * side effects (image generation, speech, transcription, uploads) are not, and
992
+ * must not be retried once the request may have reached the server. A `429` is
993
+ * always retryable regardless, since it is rejected before execution.
994
+ */
995
+ idempotent?: boolean;
966
996
  }
967
997
  /**
968
998
  * The official Skailar API client. Construct once and reuse. Resource namespaces
@@ -1049,8 +1079,14 @@ declare class Skailar {
1049
1079
  expect: "stream";
1050
1080
  }): Promise<ChatCompletionStream>;
1051
1081
  /**
1052
- * Assemble the outgoing header set for a request, applying defaults, auth,
1053
- * content-type and accept in precedence order.
1082
+ * Assemble the outgoing header set for a request.
1083
+ *
1084
+ * Precedence (later wins): client `defaultHeaders` → the SDK's default `Accept`
1085
+ * and `Content-Type` → per-call `options.headers` → `Authorization`.
1086
+ * `Authorization` is applied **last** so a caller-supplied header (including a
1087
+ * blindly proxied incoming one) can never override or drop the bearer token and
1088
+ * leak it elsewhere. `Accept`/`Content-Type` remain overridable on purpose —
1089
+ * e.g. audio speech sends `Accept: audio/mpeg`.
1054
1090
  *
1055
1091
  * @param options - The request description.
1056
1092
  * @returns The header record to send.
@@ -1067,9 +1103,15 @@ declare class Skailar {
1067
1103
  */
1068
1104
  private toApiError;
1069
1105
  /**
1070
- * Whether a status code is eligible for automatic retry (429 and any 5xx).
1106
+ * Whether an HTTP error status is eligible for automatic retry.
1107
+ *
1108
+ * A `429` is always retryable: the gateway rejects it before executing the
1109
+ * request, so replaying it cannot duplicate side effects. A `5xx` may mean the
1110
+ * request already executed server-side, so it is retried only when the caller
1111
+ * marked the request idempotent.
1071
1112
  *
1072
1113
  * @param status - The HTTP status code.
1114
+ * @param idempotent - Whether replaying the request is safe.
1073
1115
  * @returns `true` if retryable.
1074
1116
  */
1075
1117
  private isRetryableStatus;
@@ -1140,7 +1182,14 @@ declare abstract class SkailarError extends Error {
1140
1182
  readonly status: number | null;
1141
1183
  /** Machine-readable error code from the body, if any. */
1142
1184
  readonly code: string | undefined;
1143
- /** Correlation id from the `x-request-id` response header, if any. */
1185
+ /**
1186
+ * Correlation id from the `x-request-id` response header, if any, for support.
1187
+ *
1188
+ * @remarks
1189
+ * Populated on error responses. It is **not** currently surfaced for successful
1190
+ * streaming or audio responses (which return a stream rather than a wrapper),
1191
+ * so capture it from a thrown {@link SkailarError} when filing a ticket.
1192
+ */
1144
1193
  readonly requestId: string | undefined;
1145
1194
  /** The raw response body captured for debugging. */
1146
1195
  readonly raw: unknown;
package/dist/index.js CHANGED
@@ -23,7 +23,14 @@ var SkailarError = class extends Error {
23
23
  status;
24
24
  /** Machine-readable error code from the body, if any. */
25
25
  code;
26
- /** Correlation id from the `x-request-id` response header, if any. */
26
+ /**
27
+ * Correlation id from the `x-request-id` response header, if any, for support.
28
+ *
29
+ * @remarks
30
+ * Populated on error responses. It is **not** currently surfaced for successful
31
+ * streaming or audio responses (which return a stream rather than a wrapper),
32
+ * so capture it from a thrown {@link SkailarError} when filing a ticket.
33
+ */
27
34
  requestId;
28
35
  /** The raw response body captured for debugging. */
29
36
  raw;
@@ -134,6 +141,25 @@ async function* parseSSE(stream, signal) {
134
141
  const reader = stream.getReader();
135
142
  const decoder = new TextDecoder();
136
143
  let buffer = "";
144
+ function nextLine() {
145
+ let i = -1;
146
+ for (let j = 0; j < buffer.length; j++) {
147
+ const c = buffer[j];
148
+ if (c === "\n" || c === "\r") {
149
+ i = j;
150
+ break;
151
+ }
152
+ }
153
+ if (i === -1) return null;
154
+ const line = buffer.slice(0, i);
155
+ if (buffer[i] === "\r") {
156
+ if (i === buffer.length - 1) return null;
157
+ buffer = buffer.slice(i + (buffer[i + 1] === "\n" ? 2 : 1));
158
+ } else {
159
+ buffer = buffer.slice(i + 1);
160
+ }
161
+ return line;
162
+ }
137
163
  try {
138
164
  while (true) {
139
165
  if (signal?.aborted) {
@@ -142,11 +168,8 @@ async function* parseSSE(stream, signal) {
142
168
  const { done, value } = await reader.read();
143
169
  if (done) break;
144
170
  buffer += decoder.decode(value, { stream: true });
145
- let newlineIndex;
146
- while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
147
- const rawLine = buffer.slice(0, newlineIndex);
148
- buffer = buffer.slice(newlineIndex + 1);
149
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
171
+ let line;
172
+ while ((line = nextLine()) !== null) {
150
173
  if (line === "" || line.startsWith(":")) continue;
151
174
  if (!line.startsWith("data:")) continue;
152
175
  const data = line.slice(5).trimStart();
@@ -154,7 +177,7 @@ async function* parseSSE(stream, signal) {
154
177
  yield data;
155
178
  }
156
179
  }
157
- const tail = buffer.trim();
180
+ const tail = buffer.replace(/[\r\n]+$/, "");
158
181
  if (tail.startsWith("data:")) {
159
182
  const data = tail.slice(5).trimStart();
160
183
  if (data !== "[DONE]" && data !== "") yield data;
@@ -190,12 +213,15 @@ var ChatCompletionStream = class {
190
213
  *
191
214
  * Each `data:` payload is `JSON.parse`d. If a payload carries an `error` field
192
215
  * (the gateway's in-band failure signal), iteration throws the corresponding
193
- * {@link SkailarAPIError} instead of yielding. Malformed JSON payloads are
194
- * skipped defensively.
216
+ * {@link SkailarAPIError} instead of yielding. A payload that is not valid JSON
217
+ * (e.g. a plain-text error injected by an intermediary) throws a
218
+ * {@link SkailarConnectionError} rather than being dropped, so a malformed
219
+ * stream cannot truncate the response silently.
195
220
  *
196
221
  * @returns An async generator over {@link ChatCompletionChunk} values.
197
222
  * @throws {@link SkailarAPIError} When the stream delivers an in-band error event.
198
- * @throws {@link SkailarConnectionError} When the stream is aborted or read fails.
223
+ * @throws {@link SkailarConnectionError} When a payload is malformed, or the
224
+ * stream is aborted or fails to read.
199
225
  */
200
226
  async *decode() {
201
227
  for await (const data of parseSSE(this.body, this.controller.signal)) {
@@ -203,7 +229,9 @@ var ChatCompletionStream = class {
203
229
  try {
204
230
  parsed = JSON.parse(data);
205
231
  } catch {
206
- continue;
232
+ throw new SkailarConnectionError({
233
+ message: `Malformed streaming event (not JSON): ${data.slice(0, 200)}`
234
+ });
207
235
  }
208
236
  if (parsed !== null && typeof parsed === "object" && "error" in parsed) {
209
237
  const { code, message } = parseErrorBody(parsed);
@@ -270,6 +298,8 @@ var ChatCompletions = class {
270
298
  path: "/v1/chat/completions",
271
299
  body,
272
300
  expect: "stream",
301
+ // Text generation is safe to replay before the stream starts.
302
+ idempotent: true,
273
303
  signal: options?.signal,
274
304
  timeout: options?.timeout,
275
305
  headers: options?.headers
@@ -280,6 +310,8 @@ var ChatCompletions = class {
280
310
  path: "/v1/chat/completions",
281
311
  body,
282
312
  expect: "json",
313
+ // Text generation is safe to replay.
314
+ idempotent: true,
283
315
  signal: options?.signal,
284
316
  timeout: options?.timeout,
285
317
  headers: options?.headers
@@ -315,6 +347,7 @@ var ModelsResource = class {
315
347
  method: "GET",
316
348
  path: "/v1/models",
317
349
  expect: "json",
350
+ idempotent: true,
318
351
  signal: options?.signal,
319
352
  timeout: options?.timeout,
320
353
  headers: options?.headers
@@ -336,6 +369,7 @@ var ModelsResource = class {
336
369
  method: "GET",
337
370
  path: `/v1/models/${encoded}`,
338
371
  expect: "json",
372
+ idempotent: true,
339
373
  signal: options?.signal,
340
374
  timeout: options?.timeout,
341
375
  headers: options?.headers
@@ -368,6 +402,8 @@ var ImagesResource = class {
368
402
  path: "/v1/images/generations",
369
403
  body,
370
404
  expect: "json",
405
+ // Billed image generation: never replay on 5xx/timeout to avoid double charges.
406
+ idempotent: false,
371
407
  signal: options?.signal,
372
408
  timeout: options?.timeout,
373
409
  headers: options?.headers
@@ -428,6 +464,8 @@ var AudioTranscriptions = class {
428
464
  path: "/v1/audio/transcriptions",
429
465
  body: { base64, mime },
430
466
  expect: "json",
467
+ // Billed transcription: never replay on 5xx/timeout to avoid double charges.
468
+ idempotent: false,
431
469
  signal: options?.signal,
432
470
  timeout: options?.timeout,
433
471
  headers: options?.headers
@@ -465,6 +503,8 @@ var AudioSpeech = class {
465
503
  body: { input: params.input, voice: params.voice },
466
504
  headers: { Accept: "audio/mpeg", ...options?.headers },
467
505
  expect: "response",
506
+ // Billed speech synthesis: never replay on 5xx/timeout to avoid double charges.
507
+ idempotent: false,
468
508
  signal: options?.signal,
469
509
  timeout: options?.timeout
470
510
  });
@@ -509,7 +549,9 @@ var ImageUploads = class {
509
549
  method: "POST",
510
550
  path: "/v1/uploads/images",
511
551
  body: { base64, content_type: params.contentType },
512
- expect: "json"
552
+ expect: "json",
553
+ // Upload creates a stored asset: never replay to avoid duplicate uploads.
554
+ idempotent: false
513
555
  });
514
556
  }
515
557
  };
@@ -533,7 +575,9 @@ var FileUploads = class {
533
575
  method: "POST",
534
576
  path: "/v1/uploads/files",
535
577
  body: { base64, content_type: params.contentType },
536
- expect: "json"
578
+ expect: "json",
579
+ // Upload creates a stored asset: never replay to avoid duplicate uploads.
580
+ idempotent: false
537
581
  });
538
582
  }
539
583
  };
@@ -550,6 +594,11 @@ var UploadsResource = class {
550
594
  };
551
595
 
552
596
  // src/client.ts
597
+ var MAX_RETRY_DELAY_MS = 6e4;
598
+ function capRetryDelay(retryAfterSeconds) {
599
+ if (retryAfterSeconds === void 0) return void 0;
600
+ return Math.min(Math.max(0, retryAfterSeconds) * 1e3, MAX_RETRY_DELAY_MS);
601
+ }
553
602
  function delay(ms, signal) {
554
603
  return new Promise((resolve, reject) => {
555
604
  if (signal?.aborted) {
@@ -671,6 +720,7 @@ var Skailar = class {
671
720
  method: "GET",
672
721
  path: "/v1/ping-key",
673
722
  expect: "json",
723
+ idempotent: true,
674
724
  signal: options?.signal,
675
725
  timeout: options?.timeout,
676
726
  headers: options?.headers
@@ -681,15 +731,22 @@ var Skailar = class {
681
731
  *
682
732
  * Applies, per attempt: header assembly with bearer auth, a timeout-derived
683
733
  * {@link AbortSignal} composed with any caller signal, execution of
684
- * {@link SkailarOptions.fetch}, and error mapping. Retries HTTP 429, HTTP 5xx
685
- * and transient connection failures up to {@link Skailar.maxRetries}, backing
686
- * off with full-jitter exponential delay and honoring a server `Retry-After`
687
- * when present. Non-429 4xx responses fail fast.
734
+ * {@link SkailarOptions.fetch}, and error mapping. Backs off with full-jitter
735
+ * exponential delay, honoring a server `Retry-After` when present (capped at
736
+ * {@link MAX_RETRY_DELAY_MS} so an oversized value cannot stall the call), up
737
+ * to {@link Skailar.maxRetries}. Non-429 4xx responses fail fast.
738
+ *
739
+ * Retries are scoped to avoid duplicating side effects: an HTTP `429` is always
740
+ * retryable (rejected before execution), while a `5xx`, timeout or connection
741
+ * failure is retried **only** when `options.idempotent` is set — because the
742
+ * request may already have executed server-side. Requests with billed side
743
+ * effects (image generation, speech, transcription, uploads) leave it unset and
744
+ * are therefore never replayed once they may have reached the gateway.
688
745
  *
689
746
  * Transport failures are reported as {@link SkailarConnectionError} with a
690
747
  * message distinguishing three causes: an external `signal` abort
691
- * (non-retryable), an internal timeout once {@link Skailar.timeout} elapses
692
- * (retryable), and a generic network failure (retryable).
748
+ * (non-retryable), an internal timeout once {@link Skailar.timeout} elapses,
749
+ * and a generic network failure.
693
750
  *
694
751
  * @param options - The request description.
695
752
  * @returns The parsed JSON, raw `Response`, or {@link ChatCompletionStream}
@@ -699,6 +756,7 @@ var Skailar = class {
699
756
  const url = `${this.baseURL}${options.path}`;
700
757
  const isStream = options.expect === "stream";
701
758
  const timeoutMs = options.timeout ?? this.timeout;
759
+ const idempotent = options.idempotent ?? false;
702
760
  let attempt = 0;
703
761
  while (true) {
704
762
  const controller = new AbortController();
@@ -729,7 +787,7 @@ var Skailar = class {
729
787
  message: externallyAborted ? "Request aborted" : timedOut ? `Request timed out after ${timeoutMs}ms` : "Network request to the Skailar API failed",
730
788
  cause: err
731
789
  });
732
- if (externallyAborted || !this.shouldRetry(attempt)) throw connErr;
790
+ if (externallyAborted || !idempotent || !this.shouldRetry(attempt)) throw connErr;
733
791
  attempt += 1;
734
792
  await delay(this.backoff(attempt), options.signal);
735
793
  continue;
@@ -738,8 +796,8 @@ var Skailar = class {
738
796
  if (!response.ok) {
739
797
  detachExternal();
740
798
  const apiError = await this.toApiError(response);
741
- const retryAfterMs = apiError instanceof SkailarRateLimitError && apiError.retryAfter !== void 0 ? apiError.retryAfter * 1e3 : void 0;
742
- if (this.isRetryableStatus(response.status) && this.shouldRetry(attempt)) {
799
+ const retryAfterMs = apiError instanceof SkailarRateLimitError ? capRetryDelay(apiError.retryAfter) : void 0;
800
+ if (this.isRetryableStatus(response.status, idempotent) && this.shouldRetry(attempt)) {
743
801
  attempt += 1;
744
802
  await delay(retryAfterMs ?? this.backoff(attempt), options.signal);
745
803
  continue;
@@ -766,8 +824,14 @@ var Skailar = class {
766
824
  }
767
825
  }
768
826
  /**
769
- * Assemble the outgoing header set for a request, applying defaults, auth,
770
- * content-type and accept in precedence order.
827
+ * Assemble the outgoing header set for a request.
828
+ *
829
+ * Precedence (later wins): client `defaultHeaders` → the SDK's default `Accept`
830
+ * and `Content-Type` → per-call `options.headers` → `Authorization`.
831
+ * `Authorization` is applied **last** so a caller-supplied header (including a
832
+ * blindly proxied incoming one) can never override or drop the bearer token and
833
+ * leak it elsewhere. `Accept`/`Content-Type` remain overridable on purpose —
834
+ * e.g. audio speech sends `Accept: audio/mpeg`.
771
835
  *
772
836
  * @param options - The request description.
773
837
  * @returns The header record to send.
@@ -775,11 +839,14 @@ var Skailar = class {
775
839
  buildHeaders(options) {
776
840
  const headers = {
777
841
  ...this.defaultHeaders,
778
- Authorization: `Bearer ${this.apiKey}`,
779
842
  Accept: options.expect === "stream" ? "text/event-stream" : "application/json"
780
843
  };
781
844
  if (options.body !== void 0) headers["Content-Type"] = "application/json";
782
845
  if (options.headers) Object.assign(headers, options.headers);
846
+ for (const key of Object.keys(headers)) {
847
+ if (key.toLowerCase() === "authorization") delete headers[key];
848
+ }
849
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
783
850
  return headers;
784
851
  }
785
852
  /**
@@ -805,13 +872,20 @@ var Skailar = class {
805
872
  return SkailarAPIError.from(response.status, parsed, requestId, raw, retryAfter);
806
873
  }
807
874
  /**
808
- * Whether a status code is eligible for automatic retry (429 and any 5xx).
875
+ * Whether an HTTP error status is eligible for automatic retry.
876
+ *
877
+ * A `429` is always retryable: the gateway rejects it before executing the
878
+ * request, so replaying it cannot duplicate side effects. A `5xx` may mean the
879
+ * request already executed server-side, so it is retried only when the caller
880
+ * marked the request idempotent.
809
881
  *
810
882
  * @param status - The HTTP status code.
883
+ * @param idempotent - Whether replaying the request is safe.
811
884
  * @returns `true` if retryable.
812
885
  */
813
- isRetryableStatus(status) {
814
- return status === 429 || status >= 500;
886
+ isRetryableStatus(status, idempotent) {
887
+ if (status === 429) return true;
888
+ return status >= 500 && idempotent;
815
889
  }
816
890
  /**
817
891
  * Whether another attempt remains within the retry budget.