@volcengine/ark-runtime 1.0.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/LICENSE.txt +202 -0
- package/README.md +104 -0
- package/dist/cjs/index.js +1717 -0
- package/dist/esm/client.d.ts +97 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/config.d.ts +46 -0
- package/dist/esm/config.d.ts.map +1 -0
- package/dist/esm/encryption/encrypt-chat.d.ts +24 -0
- package/dist/esm/encryption/encrypt-chat.d.ts.map +1 -0
- package/dist/esm/encryption/index.d.ts +4 -0
- package/dist/esm/encryption/index.d.ts.map +1 -0
- package/dist/esm/encryption/key-agreement.d.ts +73 -0
- package/dist/esm/encryption/key-agreement.d.ts.map +1 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.mjs +1476 -0
- package/dist/esm/rslib-runtime.mjs +37 -0
- package/dist/esm/types/bot.d.ts +109 -0
- package/dist/esm/types/bot.d.ts.map +1 -0
- package/dist/esm/types/chat-completion.d.ts +167 -0
- package/dist/esm/types/chat-completion.d.ts.map +1 -0
- package/dist/esm/types/common.d.ts +29 -0
- package/dist/esm/types/common.d.ts.map +1 -0
- package/dist/esm/types/content-generation.d.ts +118 -0
- package/dist/esm/types/content-generation.d.ts.map +1 -0
- package/dist/esm/types/context.d.ts +49 -0
- package/dist/esm/types/context.d.ts.map +1 -0
- package/dist/esm/types/embeddings.d.ts +44 -0
- package/dist/esm/types/embeddings.d.ts.map +1 -0
- package/dist/esm/types/error.d.ts +45 -0
- package/dist/esm/types/error.d.ts.map +1 -0
- package/dist/esm/types/file.d.ts +66 -0
- package/dist/esm/types/file.d.ts.map +1 -0
- package/dist/esm/types/http-request-error.d.ts +13 -0
- package/dist/esm/types/http-request-error.d.ts.map +1 -0
- package/dist/esm/types/images.d.ts +78 -0
- package/dist/esm/types/images.d.ts.map +1 -0
- package/dist/esm/types/index.d.ts +13 -0
- package/dist/esm/types/index.d.ts.map +1 -0
- package/dist/esm/types/multimodal-embedding.d.ts +56 -0
- package/dist/esm/types/multimodal-embedding.d.ts.map +1 -0
- package/dist/esm/types/responses/enums.d.ts +38 -0
- package/dist/esm/types/responses/enums.d.ts.map +1 -0
- package/dist/esm/types/responses/helpers.d.ts +22 -0
- package/dist/esm/types/responses/helpers.d.ts.map +1 -0
- package/dist/esm/types/responses/index.d.ts +4 -0
- package/dist/esm/types/responses/index.d.ts.map +1 -0
- package/dist/esm/types/responses/types.d.ts +906 -0
- package/dist/esm/types/responses/types.d.ts.map +1 -0
- package/dist/esm/types/tokenization.d.ts +22 -0
- package/dist/esm/types/tokenization.d.ts.map +1 -0
- package/dist/esm/utils/breaker-provider.d.ts +9 -0
- package/dist/esm/utils/breaker-provider.d.ts.map +1 -0
- package/dist/esm/utils/breaker.d.ts +28 -0
- package/dist/esm/utils/breaker.d.ts.map +1 -0
- package/dist/esm/utils/normalize.d.ts +51 -0
- package/dist/esm/utils/normalize.d.ts.map +1 -0
- package/dist/esm/utils/request-builder.d.ts +15 -0
- package/dist/esm/utils/request-builder.d.ts.map +1 -0
- package/dist/esm/utils/request-id.d.ts +5 -0
- package/dist/esm/utils/request-id.d.ts.map +1 -0
- package/dist/esm/utils/retry.d.ts +11 -0
- package/dist/esm/utils/retry.d.ts.map +1 -0
- package/dist/esm/utils/sse-decoder.d.ts +23 -0
- package/dist/esm/utils/sse-decoder.d.ts.map +1 -0
- package/dist/esm/utils/stream-reader.d.ts +67 -0
- package/dist/esm/utils/stream-reader.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/example/README.md +118 -0
- package/example/batch-chat.ts +64 -0
- package/example/bot-chat.ts +66 -0
- package/example/chat-completion-function-call.ts +141 -0
- package/example/chat-completion-reasoning.ts +64 -0
- package/example/chat-completion-vision.ts +70 -0
- package/example/chat-completion.ts +62 -0
- package/example/content-generation.ts +70 -0
- package/example/context.ts +69 -0
- package/example/embeddings.ts +31 -0
- package/example/file-upload.ts +53 -0
- package/example/images.ts +74 -0
- package/example/list-input-items.ts +34 -0
- package/example/multimodal-embeddings.ts +36 -0
- package/example/responses/basic.ts +75 -0
- package/example/responses/doubao-app.ts +53 -0
- package/example/responses/mcp.ts +66 -0
- package/example/responses/streaming.ts +45 -0
- package/example/responses/video.ts +74 -0
- package/example/responses/web-search.ts +52 -0
- package/example/structured-outputs.ts +71 -0
- package/example/tokenization.ts +30 -0
- package/package.json +47 -0
- package/src/client.ts +1199 -0
- package/src/config.ts +68 -0
- package/src/encryption/encrypt-chat.ts +146 -0
- package/src/encryption/index.ts +21 -0
- package/src/encryption/key-agreement.ts +270 -0
- package/src/index.ts +10 -0
- package/src/types/ark.d.ts +9 -0
- package/src/types/bot.ts +127 -0
- package/src/types/chat-completion.ts +228 -0
- package/src/types/common.ts +37 -0
- package/src/types/content-generation.ts +135 -0
- package/src/types/context.ts +59 -0
- package/src/types/embeddings.ts +74 -0
- package/src/types/error.ts +93 -0
- package/src/types/file.ts +76 -0
- package/src/types/http-request-error.ts +34 -0
- package/src/types/images.ts +102 -0
- package/src/types/index.ts +12 -0
- package/src/types/multimodal-embedding.ts +67 -0
- package/src/types/responses/enums.ts +163 -0
- package/src/types/responses/helpers.ts +67 -0
- package/src/types/responses/index.ts +3 -0
- package/src/types/responses/types.ts +1335 -0
- package/src/types/tokenization.ts +24 -0
- package/src/utils/breaker-provider.ts +17 -0
- package/src/utils/breaker.ts +56 -0
- package/src/utils/normalize.ts +154 -0
- package/src/utils/request-builder.ts +51 -0
- package/src/utils/request-id.ts +17 -0
- package/src/utils/retry.ts +76 -0
- package/src/utils/sse-decoder.ts +140 -0
- package/src/utils/stream-reader.ts +270 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HttpHeaders } from "./common";
|
|
2
|
+
|
|
3
|
+
export interface TokenizationRequest {
|
|
4
|
+
text: string | string[];
|
|
5
|
+
model: string;
|
|
6
|
+
user?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Tokenization {
|
|
10
|
+
index: number;
|
|
11
|
+
object: string;
|
|
12
|
+
total_tokens: number;
|
|
13
|
+
token_ids: number[];
|
|
14
|
+
offset_mapping: number[][];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TokenizationResponse {
|
|
18
|
+
id: string;
|
|
19
|
+
created: number;
|
|
20
|
+
model: string;
|
|
21
|
+
object: string;
|
|
22
|
+
data: Tokenization[];
|
|
23
|
+
headers?: HttpHeaders;
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Breaker } from "./breaker";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maintains a per-model Breaker instance.
|
|
5
|
+
*/
|
|
6
|
+
export class ModelBreakerProvider {
|
|
7
|
+
private breakers = new Map<string, Breaker>();
|
|
8
|
+
|
|
9
|
+
getOrCreate(model: string): Breaker {
|
|
10
|
+
let breaker = this.breakers.get(model);
|
|
11
|
+
if (!breaker) {
|
|
12
|
+
breaker = new Breaker();
|
|
13
|
+
this.breakers.set(model, breaker);
|
|
14
|
+
}
|
|
15
|
+
return breaker;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker with slow-start strategy.
|
|
3
|
+
*
|
|
4
|
+
* After reset(durationMs):
|
|
5
|
+
* - No requests allowed during the cooldown period
|
|
6
|
+
* - After cooldown: allow 1, then 2, 4, 8... requests (exponential)
|
|
7
|
+
* - After 10s past cooldown: allow all
|
|
8
|
+
*/
|
|
9
|
+
export class Breaker {
|
|
10
|
+
private allowTime: number; // timestamp (ms)
|
|
11
|
+
private waiters = new Map<string, number>(); // id → index
|
|
12
|
+
private nextIndex = 0;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.allowTime = Date.now();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a request with given waitIndex is allowed through.
|
|
20
|
+
*/
|
|
21
|
+
private allow(waitIndex: number): boolean {
|
|
22
|
+
const elapsed = (Date.now() - this.allowTime) / 1000;
|
|
23
|
+
if (elapsed <= 0) return false;
|
|
24
|
+
if (elapsed > 10) return true;
|
|
25
|
+
return waitIndex < Math.pow(2, elapsed);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private getAllowedDelayMs(): number {
|
|
29
|
+
const delay = this.allowTime - Date.now();
|
|
30
|
+
return delay < 1000 ? 1000 : delay;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reset the breaker — block all requests for `durationMs`.
|
|
35
|
+
*/
|
|
36
|
+
reset(durationMs: number): void {
|
|
37
|
+
this.allowTime = Date.now() + durationMs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wait until the breaker allows this request through.
|
|
42
|
+
*/
|
|
43
|
+
async wait(): Promise<void> {
|
|
44
|
+
const id = String(this.nextIndex++);
|
|
45
|
+
const idx = this.waiters.size;
|
|
46
|
+
this.waiters.set(id, idx);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
while (!this.allow(idx)) {
|
|
50
|
+
await new Promise((r) => setTimeout(r, this.getAllowedDelayMs()));
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
this.waiters.delete(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response normalization utilities.
|
|
3
|
+
*
|
|
4
|
+
* The Go SDK serializes responses through Go structs, which means:
|
|
5
|
+
* - Fields WITHOUT `omitempty` are always present (even when zero/null)
|
|
6
|
+
* - Fields WITH `omitempty` are omitted when zero/null/empty
|
|
7
|
+
*
|
|
8
|
+
* The Node.js SDK receives raw JSON from the server (via Axios / JSON.parse),
|
|
9
|
+
* so fields may or may not be present depending on what the server returns.
|
|
10
|
+
*
|
|
11
|
+
* These functions align Node.js responses to match Go SDK serialization behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ChatCompletionResponse,
|
|
16
|
+
ChatCompletionStreamResponse,
|
|
17
|
+
ChatCompletionChoice,
|
|
18
|
+
ChatCompletionStreamChoice,
|
|
19
|
+
} from "../types/chat-completion";
|
|
20
|
+
import type { EmbeddingResponse } from "../types/embeddings";
|
|
21
|
+
import type { Usage } from "../types/common";
|
|
22
|
+
|
|
23
|
+
// ─── Chat Completion (non-stream) ────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a ChatCompletionResponse to match Go SDK output.
|
|
27
|
+
*
|
|
28
|
+
* Go struct tags:
|
|
29
|
+
* ChatCompletionMessage.Name *string `json:"name"` → always present
|
|
30
|
+
* ChatCompletionChoice.LogProbs *LogProbs `json:"logprobs,omitempty"` → omitted when nil
|
|
31
|
+
* ChatCompletionChoice.FinishReason FinishReason `json:"finish_reason"` → always present (custom MarshalJSON: empty → null)
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeChatCompletionResponse(
|
|
34
|
+
resp: ChatCompletionResponse,
|
|
35
|
+
): ChatCompletionResponse {
|
|
36
|
+
if (resp.choices) {
|
|
37
|
+
for (const choice of resp.choices) {
|
|
38
|
+
normalizeChatCompletionChoice(choice);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return resp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeChatCompletionChoice(choice: ChatCompletionChoice): void {
|
|
45
|
+
// Go: Name *string `json:"name"` — no omitempty, always serialized
|
|
46
|
+
if (choice.message && !("name" in choice.message)) {
|
|
47
|
+
(choice.message as any).name = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Go: LogProbs *LogProbs `json:"logprobs,omitempty"` — omitted when nil
|
|
51
|
+
if ("logprobs" in choice && (choice.logprobs === null || choice.logprobs === undefined)) {
|
|
52
|
+
delete (choice as any).logprobs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Go: FinishReason `json:"finish_reason"` — always present, custom MarshalJSON
|
|
56
|
+
// (already present in server response, but ensure it exists)
|
|
57
|
+
if (!("finish_reason" in choice)) {
|
|
58
|
+
(choice as any).finish_reason = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Chat Completion Stream ──────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize a ChatCompletionStreamResponse chunk to match Go SDK output.
|
|
66
|
+
*
|
|
67
|
+
* Go struct tags:
|
|
68
|
+
* ChatCompletionStreamChoiceDelta.Content string `json:"content,omitempty"` → omitted when ""
|
|
69
|
+
* ChatCompletionStreamChoiceDelta.Role string `json:"role,omitempty"` → omitted when ""
|
|
70
|
+
* ChatCompletionStreamChoice.FinishReason FinishReason `json:"finish_reason"` → always present (null when empty)
|
|
71
|
+
* ChatCompletionStreamChoice.LogProbs *LogProbs `json:"logprobs,omitempty"` → omitted when nil
|
|
72
|
+
* ChatCompletionStreamResponse.Usage *Usage `json:"usage,omitempty"` → omitted when nil
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeChatCompletionStreamChunk(
|
|
75
|
+
chunk: ChatCompletionStreamResponse,
|
|
76
|
+
): ChatCompletionStreamResponse {
|
|
77
|
+
if (chunk.choices) {
|
|
78
|
+
for (const choice of chunk.choices) {
|
|
79
|
+
normalizeStreamChoice(choice);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Go: Usage *Usage `json:"usage,omitempty"` — omitted when nil
|
|
84
|
+
if ("usage" in chunk && (chunk.usage === null || chunk.usage === undefined)) {
|
|
85
|
+
delete (chunk as any).usage;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return chunk;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeStreamChoice(choice: ChatCompletionStreamChoice): void {
|
|
92
|
+
// Go: FinishReason `json:"finish_reason"` — no omitempty, custom MarshalJSON outputs null for empty
|
|
93
|
+
if (!("finish_reason" in choice)) {
|
|
94
|
+
(choice as any).finish_reason = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Go: LogProbs *LogProbs `json:"logprobs,omitempty"` — omitted when nil
|
|
98
|
+
if ("logprobs" in choice && (choice.logprobs === null || choice.logprobs === undefined)) {
|
|
99
|
+
delete (choice as any).logprobs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (choice.delta) {
|
|
103
|
+
// Go: Content string `json:"content,omitempty"` — omitted when empty string
|
|
104
|
+
if ("content" in choice.delta && choice.delta.content === "") {
|
|
105
|
+
delete (choice.delta as any).content;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Go: Role string `json:"role,omitempty"` — omitted when empty string
|
|
109
|
+
if ("role" in choice.delta && choice.delta.role === "") {
|
|
110
|
+
delete (choice.delta as any).role;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Embeddings ──────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize an EmbeddingResponse to match Go SDK output.
|
|
119
|
+
*
|
|
120
|
+
* Go struct tags (Usage):
|
|
121
|
+
* PromptTokens int `json:"prompt_tokens"` → always present
|
|
122
|
+
* CompletionTokens int `json:"completion_tokens"` → always present (even 0)
|
|
123
|
+
* TotalTokens int `json:"total_tokens"` → always present
|
|
124
|
+
* PromptTokensDetails PromptTokensDetail `json:"prompt_tokens_details"` → always present (even zero struct)
|
|
125
|
+
* CompletionTokensDetails CompletionTokensDetails `json:"completion_tokens_details"` → always present (even zero struct)
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeEmbeddingResponse(
|
|
128
|
+
resp: EmbeddingResponse,
|
|
129
|
+
): EmbeddingResponse {
|
|
130
|
+
if (resp.usage) {
|
|
131
|
+
normalizeUsage(resp.usage);
|
|
132
|
+
}
|
|
133
|
+
return resp;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Ensure Usage has all fields that Go always serializes (no omitempty).
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeUsage(usage: Usage): void {
|
|
140
|
+
// Go: CompletionTokens int `json:"completion_tokens"` — no omitempty
|
|
141
|
+
if (usage.completion_tokens === undefined || usage.completion_tokens === null) {
|
|
142
|
+
usage.completion_tokens = 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Go: PromptTokensDetails PromptTokensDetail `json:"prompt_tokens_details"` — no omitempty
|
|
146
|
+
if (!usage.prompt_tokens_details) {
|
|
147
|
+
usage.prompt_tokens_details = { cached_tokens: 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Go: CompletionTokensDetails CompletionTokensDetails `json:"completion_tokens_details"` — no omitempty
|
|
151
|
+
if (!usage.completion_tokens_details) {
|
|
152
|
+
usage.completion_tokens_details = { reasoning_tokens: 0 };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AxiosRequestConfig } from "axios";
|
|
2
|
+
import { genRequestId } from "./request-id";
|
|
3
|
+
import { ClientRequestHeader } from "../types/common";
|
|
4
|
+
|
|
5
|
+
export interface RequestOptions {
|
|
6
|
+
body?: unknown;
|
|
7
|
+
contentType?: string;
|
|
8
|
+
projectName?: string;
|
|
9
|
+
customHeaders?: Record<string, string>;
|
|
10
|
+
query?: Record<string, string>;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
stream?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build an AxiosRequestConfig from method, url, and request options.
|
|
17
|
+
*/
|
|
18
|
+
export function buildRequest(
|
|
19
|
+
method: string,
|
|
20
|
+
url: string,
|
|
21
|
+
authHeader: Record<string, string>,
|
|
22
|
+
opts: RequestOptions = {},
|
|
23
|
+
): AxiosRequestConfig {
|
|
24
|
+
const headers: Record<string, string> = {
|
|
25
|
+
[ClientRequestHeader]: genRequestId(),
|
|
26
|
+
"Content-Type": opts.contentType ?? "application/json",
|
|
27
|
+
...authHeader,
|
|
28
|
+
...opts.customHeaders,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const config: AxiosRequestConfig = {
|
|
32
|
+
method: method as any,
|
|
33
|
+
url,
|
|
34
|
+
headers,
|
|
35
|
+
signal: opts.signal,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (opts.body !== undefined) {
|
|
39
|
+
config.data = opts.body;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (opts.query) {
|
|
43
|
+
config.params = opts.query;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (opts.stream) {
|
|
47
|
+
config.responseType = "stream";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a request ID in the format: YYYYMMDDHHmmss + 20 hex digits.
|
|
5
|
+
*/
|
|
6
|
+
export function genRequestId(): string {
|
|
7
|
+
const now = new Date();
|
|
8
|
+
const ts =
|
|
9
|
+
now.getFullYear().toString() +
|
|
10
|
+
String(now.getMonth() + 1).padStart(2, "0") +
|
|
11
|
+
String(now.getDate()).padStart(2, "0") +
|
|
12
|
+
String(now.getHours()).padStart(2, "0") +
|
|
13
|
+
String(now.getMinutes()).padStart(2, "0") +
|
|
14
|
+
String(now.getSeconds()).padStart(2, "0");
|
|
15
|
+
const hex = randomBytes(10).toString("hex");
|
|
16
|
+
return ts + hex;
|
|
17
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface RetryPolicy {
|
|
2
|
+
maxAttempts: number;
|
|
3
|
+
initialBackoffMs: number;
|
|
4
|
+
maxBackoffMs: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calculate retry delay using ExponentialWithRandomJitter strategy.
|
|
9
|
+
* Algorithm aligned with sdk-core calculateRetryDelay
|
|
10
|
+
* (packages/sdk-core/src/utils/retry.ts).
|
|
11
|
+
*/
|
|
12
|
+
function calculateRetryDelay(
|
|
13
|
+
attemptNumber: number,
|
|
14
|
+
minRetryDelay: number,
|
|
15
|
+
maxRetryDelay: number,
|
|
16
|
+
): number {
|
|
17
|
+
const base = Math.min(
|
|
18
|
+
minRetryDelay * Math.pow(2, attemptNumber - 1),
|
|
19
|
+
maxRetryDelay,
|
|
20
|
+
);
|
|
21
|
+
return Math.floor(Math.min(maxRetryDelay, base + Math.random() * base));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Retry a function with exponential backoff and jitter.
|
|
26
|
+
* Respects AbortSignal for cancellation.
|
|
27
|
+
*/
|
|
28
|
+
export async function retry<T>(
|
|
29
|
+
policy: RetryPolicy,
|
|
30
|
+
fn: () => Promise<T>,
|
|
31
|
+
isRetryable: (err: unknown) => boolean,
|
|
32
|
+
signal?: AbortSignal,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
let lastError: unknown;
|
|
35
|
+
for (let attempt = 0; attempt <= policy.maxAttempts; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
lastError = err;
|
|
40
|
+
|
|
41
|
+
if (!isRetryable(err)) {
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
if (attempt === policy.maxAttempts) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const delayMs = calculateRetryDelay(
|
|
49
|
+
attempt + 1,
|
|
50
|
+
policy.initialBackoffMs,
|
|
51
|
+
policy.maxBackoffMs,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await sleep(delayMs, signal);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
61
|
+
return new Promise<void>((resolve, reject) => {
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const timer = setTimeout(resolve, ms);
|
|
67
|
+
signal?.addEventListener(
|
|
68
|
+
"abort",
|
|
69
|
+
() => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
72
|
+
},
|
|
73
|
+
{ once: true },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE event parsed from a text/event-stream.
|
|
3
|
+
*/
|
|
4
|
+
export interface SSEEvent {
|
|
5
|
+
event: string;
|
|
6
|
+
data: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* EventStreamDecoder parses a text/event-stream from a Node readable stream.
|
|
11
|
+
* Yields SSEEvent objects via the async iterator protocol.
|
|
12
|
+
*/
|
|
13
|
+
export class EventStreamDecoder {
|
|
14
|
+
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
15
|
+
private decoder = new TextDecoder();
|
|
16
|
+
private buffer = "";
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private stream:
|
|
20
|
+
| ReadableStream<Uint8Array>
|
|
21
|
+
| NodeJS.ReadableStream,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async *[Symbol.asyncIterator](): AsyncIterator<SSEEvent> {
|
|
25
|
+
const lines = this.readLines();
|
|
26
|
+
let event = "";
|
|
27
|
+
let dataLines: string[] = [];
|
|
28
|
+
|
|
29
|
+
for await (const line of lines) {
|
|
30
|
+
// Empty line → dispatch event
|
|
31
|
+
if (line === "") {
|
|
32
|
+
if (dataLines.length > 0) {
|
|
33
|
+
yield {
|
|
34
|
+
event,
|
|
35
|
+
data: dataLines.join("\n"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
event = "";
|
|
39
|
+
dataLines = [];
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Comment line
|
|
44
|
+
if (line.startsWith(":")) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const colonIdx = line.indexOf(":");
|
|
49
|
+
let field: string;
|
|
50
|
+
let value: string;
|
|
51
|
+
|
|
52
|
+
if (colonIdx === -1) {
|
|
53
|
+
field = line;
|
|
54
|
+
value = "";
|
|
55
|
+
} else {
|
|
56
|
+
field = line.slice(0, colonIdx);
|
|
57
|
+
value = line.slice(colonIdx + 1);
|
|
58
|
+
// Remove optional leading space after colon
|
|
59
|
+
if (value.startsWith(" ")) {
|
|
60
|
+
value = value.slice(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
switch (field) {
|
|
65
|
+
case "event":
|
|
66
|
+
event = value;
|
|
67
|
+
break;
|
|
68
|
+
case "data":
|
|
69
|
+
dataLines.push(value);
|
|
70
|
+
break;
|
|
71
|
+
// id, retry etc. — ignored for our use case
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Flush remaining
|
|
76
|
+
if (dataLines.length > 0) {
|
|
77
|
+
yield { event, data: dataLines.join("\n") };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async *readLines(): AsyncGenerator<string> {
|
|
82
|
+
// Handle both Web ReadableStream and Node ReadableStream
|
|
83
|
+
const chunks = this.iterateStream();
|
|
84
|
+
|
|
85
|
+
for await (const chunk of chunks) {
|
|
86
|
+
this.buffer += typeof chunk === "string"
|
|
87
|
+
? chunk
|
|
88
|
+
: this.decoder.decode(chunk, { stream: true });
|
|
89
|
+
|
|
90
|
+
const lines = this.buffer.split("\n");
|
|
91
|
+
// Keep the last partial line in the buffer
|
|
92
|
+
this.buffer = lines.pop() ?? "";
|
|
93
|
+
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
// Remove trailing \r if present
|
|
96
|
+
yield line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Flush remaining buffer
|
|
101
|
+
if (this.buffer.length > 0) {
|
|
102
|
+
yield this.buffer;
|
|
103
|
+
this.buffer = "";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async *iterateStream(): AsyncGenerator<Uint8Array | string> {
|
|
108
|
+
const stream = this.stream as any;
|
|
109
|
+
|
|
110
|
+
// Web ReadableStream
|
|
111
|
+
if (typeof stream.getReader === "function") {
|
|
112
|
+
this.reader = stream.getReader();
|
|
113
|
+
try {
|
|
114
|
+
while (true) {
|
|
115
|
+
const { done, value } = await this.reader!.read();
|
|
116
|
+
if (done) break;
|
|
117
|
+
if (value) yield value;
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
this.reader!.releaseLock();
|
|
121
|
+
this.reader = null;
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Node ReadableStream (async iterable)
|
|
127
|
+
if (Symbol.asyncIterator in stream) {
|
|
128
|
+
for await (const chunk of stream) {
|
|
129
|
+
yield chunk;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error("Unsupported stream type");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
cancel(): void {
|
|
138
|
+
this.reader?.cancel();
|
|
139
|
+
}
|
|
140
|
+
}
|