@tansr/api-client 0.2.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 +21 -0
- package/README.md +129 -0
- package/dist/client.d.ts +78 -0
- package/dist/client.js +150 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +48 -0
- package/dist/http.d.ts +57 -0
- package/dist/http.js +308 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/sse.d.ts +27 -0
- package/dist/sse.js +119 -0
- package/dist/stream.d.ts +9 -0
- package/dist/stream.js +87 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +9 -0
- package/package.json +38 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 核:鉴权头落笔、超时/取消合流、错误信封映射、meta 头消费与重试纪律。
|
|
3
|
+
*
|
|
4
|
+
* - 重试纪律:仅幂等 GET 的 429 自动重试(尊重 Retry-After,上限 2 次)。
|
|
5
|
+
* POST 数据面(chat/messages/responses)一律不自动重试——网关按请求预扣
|
|
6
|
+
* 与结算(计费面),盲重试会造成重复计费,失败语义原样交还调用方决策。
|
|
7
|
+
* - 超时语义:timeoutMs 是一次调用的**总预算(deadline)**,以调用起点计,覆盖整个
|
|
8
|
+
* 请求生命周期——非流式含体读取,流式含全程,GET 429 的每次重试等待与后续尝试
|
|
9
|
+
* 都以剩余预算为界。Retry-After 的等待钳 MAX_RETRY_AFTER_WAIT_MS 上限;剩余
|
|
10
|
+
* 预算盖不住等待时不再等待、不再重试,直接按 429 错误信封抛 TansrApiError
|
|
11
|
+
* (retryAfterMs 携服务端原值,不钳制,供调用方自行排程)。超时归因
|
|
12
|
+
* TansrConnectionError,用户 AbortSignal 取消则原样上抛不包装。
|
|
13
|
+
* - key 纪律:api key 仅存本核私有字段,只写进请求 authorization 头;
|
|
14
|
+
* 永不进错误对象/日志/序列化面(defaultHeaders 不可覆盖 authorization)。
|
|
15
|
+
*/
|
|
16
|
+
import { TansrApiError, TansrConnectionError } from './errors.js';
|
|
17
|
+
export const HEADER_REQUEST_ID = 'x-tansr-request-id';
|
|
18
|
+
export const HEADER_COST = 'x-tansr-cost';
|
|
19
|
+
export const HEADER_BALANCE = 'x-tansr-balance';
|
|
20
|
+
const HEADER_RETRY_AFTER = 'retry-after';
|
|
21
|
+
/** GET 429 重试上限(不含首次请求)。 */
|
|
22
|
+
const MAX_GET_RETRIES = 2;
|
|
23
|
+
/** Retry-After 缺失/不可解析时的回落退避(毫秒,按第 n 次重试取位)。 */
|
|
24
|
+
const FALLBACK_BACKOFF_MS = [1_000, 2_000];
|
|
25
|
+
/**
|
|
26
|
+
* 单次重试等待的上限(毫秒):Retry-After 来自对端,不钳制等于让远端决定客户端悬挂时长。
|
|
27
|
+
* 只钳等待,不改错误对象里回报的 retryAfterMs(服务端原值)。
|
|
28
|
+
*/
|
|
29
|
+
const MAX_RETRY_AFTER_WAIT_MS = 30_000;
|
|
30
|
+
const REAL_CLOCK = {
|
|
31
|
+
now: () => Date.now(),
|
|
32
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
33
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
34
|
+
};
|
|
35
|
+
/** 一次调用的总预算:以调用起点计 deadline,重试等待与每次 fetch 都以剩余预算为界。 */
|
|
36
|
+
class Deadline {
|
|
37
|
+
/** 调用方给的总预算(毫秒;超时文案回报此值)。 */
|
|
38
|
+
totalMs;
|
|
39
|
+
#endsAt;
|
|
40
|
+
#clock;
|
|
41
|
+
constructor(totalMs, clock) {
|
|
42
|
+
this.totalMs = totalMs;
|
|
43
|
+
this.#endsAt = clock.now() + totalMs;
|
|
44
|
+
this.#clock = clock;
|
|
45
|
+
}
|
|
46
|
+
/** 剩余预算(毫秒;耗尽后为 0)。 */
|
|
47
|
+
remainingMs() {
|
|
48
|
+
return Math.max(0, this.#endsAt - this.#clock.now());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function metaFromHeaders(headers) {
|
|
52
|
+
return {
|
|
53
|
+
requestId: headers.get(HEADER_REQUEST_ID),
|
|
54
|
+
cost: headers.get(HEADER_COST),
|
|
55
|
+
balance: headers.get(HEADER_BALANCE),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export class HttpCore {
|
|
59
|
+
baseUrl;
|
|
60
|
+
#apiKey;
|
|
61
|
+
#timeoutMs;
|
|
62
|
+
#fetchImpl;
|
|
63
|
+
#defaultHeaders;
|
|
64
|
+
#clock;
|
|
65
|
+
constructor(opts) {
|
|
66
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
67
|
+
this.#apiKey = opts.apiKey;
|
|
68
|
+
this.#timeoutMs = opts.timeoutMs;
|
|
69
|
+
this.#fetchImpl = opts.fetchImpl;
|
|
70
|
+
this.#defaultHeaders = opts.defaultHeaders;
|
|
71
|
+
this.#clock = opts.clock ?? REAL_CLOCK;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 非流式 JSON 请求(GET 走 429 重试纪律;2xx 断言对象体并回 meta)。
|
|
75
|
+
* 整个循环(每次 fetch + 体读取 + 重试等待)共用一个以调用起点计的总预算。
|
|
76
|
+
*/
|
|
77
|
+
async requestJson(method, path, args, opts = {}) {
|
|
78
|
+
const url = this.baseUrl + path + buildQuery(args.query);
|
|
79
|
+
const hasBody = args.body !== undefined;
|
|
80
|
+
const init = {
|
|
81
|
+
method,
|
|
82
|
+
headers: this.#headers('application/json', hasBody),
|
|
83
|
+
...(hasBody ? { body: JSON.stringify(args.body) } : {}),
|
|
84
|
+
};
|
|
85
|
+
const maxRetries = method === 'GET' ? MAX_GET_RETRIES : 0;
|
|
86
|
+
const deadline = new Deadline(opts.timeoutMs ?? this.#timeoutMs, this.#clock);
|
|
87
|
+
for (let attempt = 0;; attempt++) {
|
|
88
|
+
const timed = await this.#fetchWithTimeout(url, init, opts, deadline);
|
|
89
|
+
const { response } = timed;
|
|
90
|
+
if (response.status === 429 && attempt < maxRetries) {
|
|
91
|
+
const advertisedMs = retryAfterMsFromHeader(response, this.#clock) ??
|
|
92
|
+
FALLBACK_BACKOFF_MS[Math.min(attempt, FALLBACK_BACKOFF_MS.length - 1)] ??
|
|
93
|
+
1_000;
|
|
94
|
+
const waitMs = Math.min(advertisedMs, MAX_RETRY_AFTER_WAIT_MS);
|
|
95
|
+
if (waitMs < deadline.remainingMs()) {
|
|
96
|
+
await response.body?.cancel().catch(() => undefined);
|
|
97
|
+
timed.finish();
|
|
98
|
+
await sleep(waitMs, opts.signal, this.#clock);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// 剩余预算盖不住(钳制后的)等待:不再等待、不再重试,落到下方按 429 错误信封抛出
|
|
102
|
+
// (TansrApiError.retryAfterMs 携服务端原值,调用方可自行排程)。
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
throw await this.#toApiError(response);
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
text = await response.text();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw this.#mapBodyReadError(err, timed, opts);
|
|
113
|
+
}
|
|
114
|
+
let json;
|
|
115
|
+
try {
|
|
116
|
+
json = JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
throw new TansrConnectionError('gateway returned invalid JSON', { cause: err });
|
|
120
|
+
}
|
|
121
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
122
|
+
throw new TansrConnectionError('gateway returned a non-object JSON body');
|
|
123
|
+
}
|
|
124
|
+
return { json: json, meta: metaFromHeaders(response.headers) };
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
timed.finish();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 流式请求(POST,accept: text/event-stream):零重试(计费面,见头注)。
|
|
133
|
+
* 非 2xx 在此按错误信封抛出;2xx 交还句柄由 TansrStream 逐帧消费。
|
|
134
|
+
*/
|
|
135
|
+
async openStream(path, body, opts = {}) {
|
|
136
|
+
const url = this.baseUrl + path;
|
|
137
|
+
const init = {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: this.#headers('text/event-stream', true),
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
};
|
|
142
|
+
const deadline = new Deadline(opts.timeoutMs ?? this.#timeoutMs, this.#clock);
|
|
143
|
+
const timed = await this.#fetchWithTimeout(url, init, opts, deadline);
|
|
144
|
+
if (!timed.response.ok) {
|
|
145
|
+
try {
|
|
146
|
+
throw await this.#toApiError(timed.response);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
timed.finish();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
response: timed.response,
|
|
154
|
+
meta: metaFromHeaders(timed.response.headers),
|
|
155
|
+
finish: timed.finish,
|
|
156
|
+
mapStreamError: (err) => this.#mapBodyReadError(err, timed, opts),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
#headers(accept, hasBody) {
|
|
160
|
+
const headers = { ...this.#defaultHeaders, accept };
|
|
161
|
+
if (hasBody)
|
|
162
|
+
headers['content-type'] = 'application/json';
|
|
163
|
+
// authorization 恒由本核最后落笔:defaultHeaders 不可覆盖(防误配旁路鉴权)
|
|
164
|
+
headers.authorization = `Bearer ${this.#apiKey}`;
|
|
165
|
+
return headers;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 单次 fetch:用户 signal 与超时合流;失败即时归因(超时/用户取消/网络)。
|
|
169
|
+
* 超时计时器取 deadline 的**剩余**预算(重试时不重新起算),覆盖本次 fetch 及其后的体读取。
|
|
170
|
+
*/
|
|
171
|
+
async #fetchWithTimeout(url, init, opts, deadline) {
|
|
172
|
+
const ctl = new AbortController();
|
|
173
|
+
let timedOut = false;
|
|
174
|
+
const timer = this.#clock.setTimeout(() => {
|
|
175
|
+
timedOut = true;
|
|
176
|
+
ctl.abort();
|
|
177
|
+
}, deadline.remainingMs());
|
|
178
|
+
const signal = opts.signal !== undefined ? AbortSignal.any([opts.signal, ctl.signal]) : ctl.signal;
|
|
179
|
+
try {
|
|
180
|
+
const response = await this.#fetchImpl(url, { ...init, signal });
|
|
181
|
+
return { response, finish: () => this.#clock.clearTimeout(timer), timedOut: () => timedOut, deadline };
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
this.#clock.clearTimeout(timer);
|
|
185
|
+
if (timedOut)
|
|
186
|
+
throw new TansrConnectionError(`request timed out after ${deadline.totalMs}ms`);
|
|
187
|
+
if (opts.signal?.aborted === true)
|
|
188
|
+
throw err; // 用户取消:原样上抛
|
|
189
|
+
throw new TansrConnectionError('failed to reach the tansr gateway', { cause: err });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** 响应体读取中断归因(非流式 text() 与流式逐帧共用)。 */
|
|
193
|
+
#mapBodyReadError(err, timed, opts) {
|
|
194
|
+
if (timed.timedOut()) {
|
|
195
|
+
return new TansrConnectionError(`request timed out after ${timed.deadline.totalMs}ms`);
|
|
196
|
+
}
|
|
197
|
+
if (opts.signal?.aborted === true)
|
|
198
|
+
return err; // 用户取消:原样上抛
|
|
199
|
+
return new TansrConnectionError('failed to read the gateway response', { cause: err });
|
|
200
|
+
}
|
|
201
|
+
/** 非 2xx → TansrApiError:tansr 信封优先,上游透传体尽力提取,双兜底。 */
|
|
202
|
+
async #toApiError(response) {
|
|
203
|
+
let text = '';
|
|
204
|
+
try {
|
|
205
|
+
text = await response.text();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// 错误体不可读:保底以状态码成错
|
|
209
|
+
}
|
|
210
|
+
const parsed = parseErrorBody(text);
|
|
211
|
+
const headerRetryMs = retryAfterMsFromHeader(response, this.#clock);
|
|
212
|
+
const detailRetrySec = parsed.detail?.retryAfterSec;
|
|
213
|
+
const detailRetryMs = typeof detailRetrySec === 'number' && Number.isFinite(detailRetrySec)
|
|
214
|
+
? Math.max(0, Math.round(detailRetrySec * 1_000))
|
|
215
|
+
: null;
|
|
216
|
+
return new TansrApiError({
|
|
217
|
+
status: response.status,
|
|
218
|
+
code: parsed.code ?? `http_${response.status}`,
|
|
219
|
+
message: parsed.message ?? (text !== '' ? snippet(text) : `gateway responded with HTTP ${response.status}`),
|
|
220
|
+
requestId: parsed.requestId ?? response.headers.get(HEADER_REQUEST_ID),
|
|
221
|
+
detail: parsed.detail,
|
|
222
|
+
retryAfterMs: headerRetryMs ?? detailRetryMs,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 错误体解析:tansr 信封 { error: { code, message, requestId, detail } };
|
|
228
|
+
* 上游透传(OpenAI 形 error.type / Anthropic 形 { type:'error', error:{…} })
|
|
229
|
+
* 同径尽力提取;非 JSON 返回全 null 由调用侧兜底。
|
|
230
|
+
*/
|
|
231
|
+
function parseErrorBody(text) {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(text);
|
|
234
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
235
|
+
const errField = parsed.error;
|
|
236
|
+
if (typeof errField === 'object' && errField !== null) {
|
|
237
|
+
const e = errField;
|
|
238
|
+
return {
|
|
239
|
+
code: typeof e.code === 'string' ? e.code : typeof e.type === 'string' ? e.type : null,
|
|
240
|
+
message: typeof e.message === 'string' ? e.message : null,
|
|
241
|
+
requestId: typeof e.requestId === 'string' ? e.requestId : null,
|
|
242
|
+
detail: isPlainObject(e.detail) ? e.detail : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// 非 JSON 错误体(上游透传任意文本)
|
|
249
|
+
}
|
|
250
|
+
return { code: null, message: null, requestId: null, detail: null };
|
|
251
|
+
}
|
|
252
|
+
function isPlainObject(value) {
|
|
253
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Retry-After 头 → 毫秒:秒数(整数;小数秒同 detail.retryAfterSec 一并接受)或 HTTP-date;
|
|
257
|
+
* 不可解析回 null。HTTP-date 三种形态(IMF-fixdate / RFC 850 / asctime)必含月名字母,
|
|
258
|
+
* 无字母的串不交给 Date.parse——V8 会把 "0.05" 之类数字串宽松解析成过去日期,等价于
|
|
259
|
+
* 把不合规头当成 0 等待。
|
|
260
|
+
*/
|
|
261
|
+
function retryAfterMsFromHeader(response, clock) {
|
|
262
|
+
const raw = response.headers.get(HEADER_RETRY_AFTER);
|
|
263
|
+
if (raw === null)
|
|
264
|
+
return null;
|
|
265
|
+
const trimmed = raw.trim();
|
|
266
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
267
|
+
return Math.round(Number(trimmed) * 1_000);
|
|
268
|
+
if (!/[A-Za-z]/.test(trimmed))
|
|
269
|
+
return null;
|
|
270
|
+
const dateMs = Date.parse(trimmed);
|
|
271
|
+
if (!Number.isNaN(dateMs))
|
|
272
|
+
return Math.max(0, dateMs - clock.now());
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function snippet(text) {
|
|
276
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
277
|
+
return collapsed.length > 240 ? `${collapsed.slice(0, 237)}...` : collapsed;
|
|
278
|
+
}
|
|
279
|
+
function buildQuery(query) {
|
|
280
|
+
if (query === undefined)
|
|
281
|
+
return '';
|
|
282
|
+
const params = new URLSearchParams();
|
|
283
|
+
for (const [key, value] of Object.entries(query)) {
|
|
284
|
+
if (value !== undefined && value !== '')
|
|
285
|
+
params.set(key, value);
|
|
286
|
+
}
|
|
287
|
+
const s = params.toString();
|
|
288
|
+
return s === '' ? '' : `?${s}`;
|
|
289
|
+
}
|
|
290
|
+
/** 重试等待:用户取消立即以 signal.reason 拒绝(与 fetch 的取消语义一致)。 */
|
|
291
|
+
function sleep(ms, signal, clock) {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const reasonOf = (s) => s.reason ?? new DOMException('This operation was aborted', 'AbortError');
|
|
294
|
+
if (signal?.aborted === true) {
|
|
295
|
+
reject(reasonOf(signal));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const onAbort = () => {
|
|
299
|
+
clock.clearTimeout(timer);
|
|
300
|
+
reject(reasonOf(signal));
|
|
301
|
+
};
|
|
302
|
+
const timer = clock.setTimeout(() => {
|
|
303
|
+
signal?.removeEventListener('abort', onAbort);
|
|
304
|
+
resolve();
|
|
305
|
+
}, ms);
|
|
306
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
307
|
+
});
|
|
308
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tansr/api-client 公共出口。
|
|
3
|
+
*
|
|
4
|
+
* 运行时导出:Tansr(默认导出同名)、TansrApiError / TansrConnectionError、
|
|
5
|
+
* TansrStream(流返回值类型,亦可 instanceof)。其余为纯类型导出。
|
|
6
|
+
*/
|
|
7
|
+
export { Tansr, ChatCompletions, Messages, Responses, Models, Usage } from './client.js';
|
|
8
|
+
export type { TansrOptions } from './client.js';
|
|
9
|
+
export { TansrApiError, TansrConnectionError } from './errors.js';
|
|
10
|
+
export type { TansrApiErrorArgs } from './errors.js';
|
|
11
|
+
export { TansrStream } from './stream.js';
|
|
12
|
+
export type { AnthropicMessage, AnthropicMessageParam, AnthropicUsage, ChatCompletion, ChatCompletionChoice, ChatCompletionChunk, ChatCompletionChunkChoice, ChatCompletionUsage, ChatCompletionsCreateParams, ChatMessageParam, MessageStreamEvent, MessagesCreateParams, ModelInfo, ModelsListResponse, RequestOptions, ResponseMeta, ResponseObject, ResponseStreamEvent, ResponsesCreateParams, UsageGetParams, UsageReport, UsageRow, WithMeta, } from './types.js';
|
|
13
|
+
import { Tansr } from './client.js';
|
|
14
|
+
export default Tansr;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tansr/api-client 公共出口。
|
|
3
|
+
*
|
|
4
|
+
* 运行时导出:Tansr(默认导出同名)、TansrApiError / TansrConnectionError、
|
|
5
|
+
* TansrStream(流返回值类型,亦可 instanceof)。其余为纯类型导出。
|
|
6
|
+
*/
|
|
7
|
+
export { Tansr, ChatCompletions, Messages, Responses, Models, Usage } from './client.js';
|
|
8
|
+
export { TansrApiError, TansrConnectionError } from './errors.js';
|
|
9
|
+
export { TansrStream } from './stream.js';
|
|
10
|
+
import { Tansr } from './client.js';
|
|
11
|
+
export default Tansr;
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 解析器:字节流 → 事件 { event, data }(WHATWG EventSource 语义的最小
|
|
3
|
+
* 零依赖实现,只取本客户端需要的子集)。
|
|
4
|
+
*
|
|
5
|
+
* - 行语义:`data:` 行(同事件多行按规范以 '\n' 连接)、`event:` 行、`:` 注释
|
|
6
|
+
* 行忽略、空行分发;`id:`/`retry:` 忽略(薄客户端无重连语义);
|
|
7
|
+
* - 行终符 CRLF / LF / CR 均支持;跨 chunk 断行(含被切开的 \r\n)与多字节
|
|
8
|
+
* UTF-8 断字由行缓冲 + TextDecoder(stream: true) 正确重组;
|
|
9
|
+
* - 行扫描线性:每次 feed 只单遍扫本次新到的文本(每个字符恰看一次),未凑满的残行
|
|
10
|
+
* 不参与再扫描——不随缓冲增长而重扫,`\r` 稀疏的大块不再退化为二次(RV-4-07);
|
|
11
|
+
* - 与规范的刻意偏差:flush() 在 EOF 时把未以空行收尾的残余事件尽力分发一次
|
|
12
|
+
* (规范是丢弃)——上游异常截断时不吞掉最后一帧;残帧若非法 JSON 由消费侧
|
|
13
|
+
* 自然丢弃。无 data 行的事件(如裸 event: 行)不分发。
|
|
14
|
+
*/
|
|
15
|
+
export interface SseEvent {
|
|
16
|
+
/** `event:` 行给出的事件名;OpenAI chat 流无事件名,为 null。 */
|
|
17
|
+
readonly event: string | null;
|
|
18
|
+
/** `data:` 行内容(多行以 '\n' 连接)。 */
|
|
19
|
+
readonly data: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class SseParser {
|
|
22
|
+
#private;
|
|
23
|
+
/** 喂入一个网络分帧,返回其中凑齐的完整事件(可能为 0..n 个)。 */
|
|
24
|
+
feed(chunk: Uint8Array): SseEvent[];
|
|
25
|
+
/** EOF 收尾:冲刷解码器与残行,分发未收尾的残余事件(见头注偏差说明)。 */
|
|
26
|
+
flush(): SseEvent[];
|
|
27
|
+
}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 解析器:字节流 → 事件 { event, data }(WHATWG EventSource 语义的最小
|
|
3
|
+
* 零依赖实现,只取本客户端需要的子集)。
|
|
4
|
+
*
|
|
5
|
+
* - 行语义:`data:` 行(同事件多行按规范以 '\n' 连接)、`event:` 行、`:` 注释
|
|
6
|
+
* 行忽略、空行分发;`id:`/`retry:` 忽略(薄客户端无重连语义);
|
|
7
|
+
* - 行终符 CRLF / LF / CR 均支持;跨 chunk 断行(含被切开的 \r\n)与多字节
|
|
8
|
+
* UTF-8 断字由行缓冲 + TextDecoder(stream: true) 正确重组;
|
|
9
|
+
* - 行扫描线性:每次 feed 只单遍扫本次新到的文本(每个字符恰看一次),未凑满的残行
|
|
10
|
+
* 不参与再扫描——不随缓冲增长而重扫,`\r` 稀疏的大块不再退化为二次(RV-4-07);
|
|
11
|
+
* - 与规范的刻意偏差:flush() 在 EOF 时把未以空行收尾的残余事件尽力分发一次
|
|
12
|
+
* (规范是丢弃)——上游异常截断时不吞掉最后一帧;残帧若非法 JSON 由消费侧
|
|
13
|
+
* 自然丢弃。无 data 行的事件(如裸 event: 行)不分发。
|
|
14
|
+
*/
|
|
15
|
+
const LF = 0x0a;
|
|
16
|
+
const CR = 0x0d;
|
|
17
|
+
export class SseParser {
|
|
18
|
+
#decoder = new TextDecoder('utf-8');
|
|
19
|
+
/** 未凑满一行的残余文本(不含任何行终符;跨 chunk 断行重组缓冲,只在该行凑齐时拼接一次)。 */
|
|
20
|
+
#tail = '';
|
|
21
|
+
/** 上一 chunk 以 \r 收尾:该行已按 CR 行终分发;下一 chunk 若以 \n 开头,它是被切开的 \r\n 后半,吞掉。 */
|
|
22
|
+
#skipLf = false;
|
|
23
|
+
#eventName = null;
|
|
24
|
+
#dataLines = [];
|
|
25
|
+
/** 喂入一个网络分帧,返回其中凑齐的完整事件(可能为 0..n 个)。 */
|
|
26
|
+
feed(chunk) {
|
|
27
|
+
const events = [];
|
|
28
|
+
this.#consume(this.#decoder.decode(chunk, { stream: true }), events);
|
|
29
|
+
return events;
|
|
30
|
+
}
|
|
31
|
+
/** EOF 收尾:冲刷解码器与残行,分发未收尾的残余事件(见头注偏差说明)。 */
|
|
32
|
+
flush() {
|
|
33
|
+
const events = [];
|
|
34
|
+
this.#consume(this.#decoder.decode(), events);
|
|
35
|
+
this.#skipLf = false;
|
|
36
|
+
if (this.#tail !== '') {
|
|
37
|
+
const line = this.#tail;
|
|
38
|
+
this.#tail = '';
|
|
39
|
+
this.#line(line, events);
|
|
40
|
+
}
|
|
41
|
+
const pending = this.#dispatch();
|
|
42
|
+
if (pending !== null)
|
|
43
|
+
events.push(pending);
|
|
44
|
+
return events;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 单遍线性扫描本次新到的文本:遇 \n 或 \r 即成行(\r 后紧跟的 \n 属同一 CRLF,跳过);
|
|
48
|
+
* \r 落在文本末字节时按 CR 行终立即成行,并记下"下一 chunk 首字节若为 \n 须吞掉"。
|
|
49
|
+
* 残余 #tail 已知不含行终符,只在首行凑齐时与之拼接一次,不随缓冲增长而重扫。
|
|
50
|
+
*/
|
|
51
|
+
#consume(text, events) {
|
|
52
|
+
const len = text.length;
|
|
53
|
+
let pos = 0;
|
|
54
|
+
if (this.#skipLf && len > 0) {
|
|
55
|
+
this.#skipLf = false;
|
|
56
|
+
if (text.charCodeAt(0) === LF)
|
|
57
|
+
pos = 1;
|
|
58
|
+
}
|
|
59
|
+
let i = pos;
|
|
60
|
+
while (i < len) {
|
|
61
|
+
const code = text.charCodeAt(i);
|
|
62
|
+
if (code !== LF && code !== CR) {
|
|
63
|
+
i += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
this.#line(this.#takeLine(text, pos, i), events);
|
|
67
|
+
if (code === CR) {
|
|
68
|
+
if (i + 1 < len) {
|
|
69
|
+
if (text.charCodeAt(i + 1) === LF)
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.#skipLf = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
pos = i;
|
|
78
|
+
}
|
|
79
|
+
if (pos < len)
|
|
80
|
+
this.#tail += pos === 0 ? text : text.slice(pos);
|
|
81
|
+
}
|
|
82
|
+
/** 取出 [pos, end) 作为一行;若有残余 #tail,拼在行首并清空(仅首行会命中)。 */
|
|
83
|
+
#takeLine(text, pos, end) {
|
|
84
|
+
const piece = text.slice(pos, end);
|
|
85
|
+
if (this.#tail === '')
|
|
86
|
+
return piece;
|
|
87
|
+
const line = this.#tail + piece;
|
|
88
|
+
this.#tail = '';
|
|
89
|
+
return line;
|
|
90
|
+
}
|
|
91
|
+
#line(line, events) {
|
|
92
|
+
if (line === '') {
|
|
93
|
+
const ev = this.#dispatch();
|
|
94
|
+
if (ev !== null)
|
|
95
|
+
events.push(ev);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (line.startsWith(':'))
|
|
99
|
+
return; // 注释行(保活 ping 等)
|
|
100
|
+
const colon = line.indexOf(':');
|
|
101
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
102
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
103
|
+
if (value.startsWith(' '))
|
|
104
|
+
value = value.slice(1); // 规范:仅剥一个前导空格
|
|
105
|
+
if (field === 'data')
|
|
106
|
+
this.#dataLines.push(value);
|
|
107
|
+
else if (field === 'event')
|
|
108
|
+
this.#eventName = value;
|
|
109
|
+
// id / retry / 未知字段:忽略
|
|
110
|
+
}
|
|
111
|
+
#dispatch() {
|
|
112
|
+
const ev = this.#dataLines.length > 0
|
|
113
|
+
? { event: this.#eventName, data: this.#dataLines.join('\n') }
|
|
114
|
+
: null;
|
|
115
|
+
this.#eventName = null;
|
|
116
|
+
this.#dataLines = [];
|
|
117
|
+
return ev;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StreamHandle } from './http.js';
|
|
2
|
+
import type { ResponseMeta } from './types.js';
|
|
3
|
+
export declare class TansrStream<T> implements AsyncIterable<T> {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(open: () => Promise<StreamHandle>);
|
|
6
|
+
/** 逐请求元信息(消费响应头)。迭代开始前为 null;响应头到达后可读,迭代完成后必有。 */
|
|
7
|
+
get meta(): ResponseMeta | null;
|
|
8
|
+
[Symbol.asyncIterator](): AsyncGenerator<T, void, undefined>;
|
|
9
|
+
}
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TansrStream:SSE 响应 → AsyncIterable<逐帧对象>。
|
|
3
|
+
*
|
|
4
|
+
* - 惰性起流:首次迭代才发请求(单次可迭代;HTTP 错误在首个 next() 以
|
|
5
|
+
* TansrApiError 拒绝——402/429 等与非流式同径);
|
|
6
|
+
* - 终止语义:OpenAI 系 `data: [DONE]` 哨兵即止;Anthropic 无哨兵,流体自然
|
|
7
|
+
* 结束即止(message_stop 帧原样产出);
|
|
8
|
+
* - `event:` 行语义:data 缺 type 字段时以事件名补位(Anthropic/Responses
|
|
9
|
+
* 消费侧统一按 type 分派);非 JSON 的 data 帧静默跳过;
|
|
10
|
+
* - meta:响应头到达(首帧前)即可读,迭代完成后必有;流式无 x-tansr-cost
|
|
11
|
+
* (成本流末才可知,实结见网关台账),cost 恒 null;
|
|
12
|
+
* - 取消:提前 break/throw 会取消上游读(网关按断链结算),用户 AbortSignal
|
|
13
|
+
* 的中止异常原样上抛。
|
|
14
|
+
*/
|
|
15
|
+
import { SseParser } from './sse.js';
|
|
16
|
+
const DONE_SENTINEL = '[DONE]';
|
|
17
|
+
/** 内部分派哨兵:终止 / 跳过本帧(与合法 JSON 值域零冲突)。 */
|
|
18
|
+
const DONE = Symbol('tansr.sse.done');
|
|
19
|
+
const SKIP = Symbol('tansr.sse.skip');
|
|
20
|
+
export class TansrStream {
|
|
21
|
+
#meta = null;
|
|
22
|
+
#consumed = false;
|
|
23
|
+
#open;
|
|
24
|
+
constructor(open) {
|
|
25
|
+
this.#open = open;
|
|
26
|
+
}
|
|
27
|
+
/** 逐请求元信息(消费响应头)。迭代开始前为 null;响应头到达后可读,迭代完成后必有。 */
|
|
28
|
+
get meta() {
|
|
29
|
+
return this.#meta;
|
|
30
|
+
}
|
|
31
|
+
async *[Symbol.asyncIterator]() {
|
|
32
|
+
if (this.#consumed) {
|
|
33
|
+
throw new TypeError('TansrStream is single-use: it has already been iterated');
|
|
34
|
+
}
|
|
35
|
+
this.#consumed = true;
|
|
36
|
+
const handle = await this.#open();
|
|
37
|
+
this.#meta = handle.meta;
|
|
38
|
+
const body = handle.response.body;
|
|
39
|
+
if (body === null) {
|
|
40
|
+
handle.finish();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const reader = body.getReader();
|
|
44
|
+
const parser = new SseParser();
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
let step;
|
|
48
|
+
try {
|
|
49
|
+
step = await reader.read();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw handle.mapStreamError(err);
|
|
53
|
+
}
|
|
54
|
+
const events = step.done ? parser.flush() : parser.feed(step.value);
|
|
55
|
+
for (const ev of events) {
|
|
56
|
+
const chunk = toChunk(ev);
|
|
57
|
+
if (chunk === DONE)
|
|
58
|
+
return;
|
|
59
|
+
if (chunk !== SKIP)
|
|
60
|
+
yield chunk;
|
|
61
|
+
}
|
|
62
|
+
if (step.done)
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
handle.finish();
|
|
68
|
+
// 提前 break/throw:取消上游读(读毕时为幂等空操作,失败静默——连接已亡)
|
|
69
|
+
await reader.cancel().catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function toChunk(ev) {
|
|
74
|
+
if (ev.data.trim() === DONE_SENTINEL)
|
|
75
|
+
return DONE;
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(ev.data);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return SKIP; // 非 JSON data(保活/未知帧):静默跳过
|
|
82
|
+
}
|
|
83
|
+
if (typeof parsed === 'object' && parsed !== null && ev.event !== null && !('type' in parsed)) {
|
|
84
|
+
parsed.type = ev.event;
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|