@skailar-ai/sdk 0.0.2 → 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 +102 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -19
- package/dist/index.d.ts +68 -19
- package/dist/index.js +102 -28
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
/**
|
|
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
|
|
150
|
-
while ((
|
|
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.
|
|
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.
|
|
198
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
774
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|