fg-tracker-sdk 0.11.0 → 0.13.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +76 -32
  3. package/dist/fetch.d.ts.map +1 -1
  4. package/dist/fetch.js +31 -2
  5. package/dist/fetch.js.map +1 -1
  6. package/dist/genai/bedrock.d.ts +22 -0
  7. package/dist/genai/bedrock.d.ts.map +1 -0
  8. package/dist/genai/bedrock.js +161 -0
  9. package/dist/genai/bedrock.js.map +1 -0
  10. package/dist/genai/call.d.ts +96 -0
  11. package/dist/genai/call.d.ts.map +1 -0
  12. package/dist/genai/call.js +260 -0
  13. package/dist/genai/call.js.map +1 -0
  14. package/dist/genai/config.d.ts +71 -0
  15. package/dist/genai/config.d.ts.map +1 -0
  16. package/dist/genai/config.js +110 -0
  17. package/dist/genai/config.js.map +1 -0
  18. package/dist/genai/http.d.ts +49 -0
  19. package/dist/genai/http.d.ts.map +1 -0
  20. package/dist/genai/http.js +795 -0
  21. package/dist/genai/http.js.map +1 -0
  22. package/dist/genai/semconv.d.ts +64 -0
  23. package/dist/genai/semconv.d.ts.map +1 -0
  24. package/dist/genai/semconv.js +52 -0
  25. package/dist/genai/semconv.js.map +1 -0
  26. package/dist/genai/sse.d.ts +26 -0
  27. package/dist/genai/sse.d.ts.map +1 -0
  28. package/dist/genai/sse.js +100 -0
  29. package/dist/genai/sse.js.map +1 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/infra/aws.d.ts.map +1 -1
  35. package/dist/infra/aws.js +38 -2
  36. package/dist/infra/aws.js.map +1 -1
  37. package/dist/tracker.d.ts +9 -0
  38. package/dist/tracker.d.ts.map +1 -1
  39. package/dist/tracker.js +14 -0
  40. package/dist/tracker.js.map +1 -1
  41. package/dist/types.d.ts +9 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +4 -1
@@ -0,0 +1,795 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { errorTypeOf, GenAiCall, modelName, reportedCost, tokenCount } from './call.js';
3
+ import { GEN_AI } from './semconv.js';
4
+ import { SseParser } from './sse.js';
5
+ /**
6
+ * Chamadas de IA por HTTP (OpenAI Chat Completions/Embeddings e compatíveis, Anthropic Messages)
7
+ * observadas no `fetch` instrumentado (CONTRATO.md §30). Não há span novo: os atributos vão no
8
+ * `http.client` da própria requisição, que passa a fechar quando a resposta termina.
9
+ *
10
+ * Do pedido só saem modelo e parâmetros numéricos; mensagens, prompts de sistema, ferramentas,
11
+ * identificadores de usuário e o texto das respostas nunca são lidos para atributos.
12
+ */
13
+ /** Pedido acima disto não é analisado (o modelo vem da resposta): `JSON.parse` roda no caminho da aplicação. */
14
+ const MAX_REQUEST_PARSE_BYTES = 1024 * 1024;
15
+ /** Resposta JSON analisada inteira até aqui; acima, só as chaves da raiz no fim (onde ficam `usage` e `model`). */
16
+ const MAX_RESPONSE_PARSE_BYTES = 1024 * 1024;
17
+ const RESPONSE_TAIL_BYTES = 16 * 1024;
18
+ const MAX_ERROR_BODY_BYTES = 64 * 1024;
19
+ /**
20
+ * Resposta de erro cujo corpo quem chamou cancela (os clientes OpenAI/Anthropic fazem isso antes de
21
+ * tentar de novo): a cópia ainda tem até este prazo para ler o código do provedor; depois é cancelada
22
+ * também e a conexão fecha.
23
+ */
24
+ const ERROR_BODY_GRACE_MS = 50;
25
+ const RETRY_HEADER = 'x-stainless-retry-count';
26
+ function obj(value) {
27
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
28
+ }
29
+ function finite(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
31
+ }
32
+ function int(value) {
33
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
34
+ }
35
+ export function isAbortError(err) {
36
+ const name = err?.name;
37
+ return name === 'AbortError';
38
+ }
39
+ function safeParse(text) {
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ function requestBodyText(body) {
48
+ if (typeof body === 'string')
49
+ return body.length <= MAX_REQUEST_PARSE_BYTES ? body : undefined;
50
+ if (body instanceof ArrayBuffer)
51
+ return body.byteLength <= MAX_REQUEST_PARSE_BYTES ? new TextDecoder().decode(body) : undefined;
52
+ if (ArrayBuffer.isView(body)) {
53
+ return body.byteLength <= MAX_REQUEST_PARSE_BYTES ? new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)) : undefined;
54
+ }
55
+ return undefined;
56
+ }
57
+ function headerValue(input, init, name) {
58
+ try {
59
+ if (init?.headers)
60
+ return new Headers(init.headers).get(name);
61
+ return input instanceof Request ? input.headers.get(name) : null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Parâmetros do pedido que não são conteúdo. */
68
+ function applyRequest(call, api, body) {
69
+ if (body.stream === true)
70
+ call.setRequest(GEN_AI.requestStream, true);
71
+ const maxTokens = int(body.max_completion_tokens) ?? int(body.max_tokens);
72
+ call.setRequest(GEN_AI.requestMaxTokens, maxTokens);
73
+ call.setRequest(GEN_AI.requestTemperature, finite(body.temperature));
74
+ call.setRequest(GEN_AI.requestTopP, finite(body.top_p));
75
+ if (api === 'anthropic') {
76
+ call.setRequest(GEN_AI.requestTopK, finite(body.top_k));
77
+ return;
78
+ }
79
+ if (call.operation === 'embeddings') {
80
+ if (typeof body.encoding_format === 'string' && body.encoding_format.length <= 32)
81
+ call.setRequest(GEN_AI.requestEncodingFormats, [body.encoding_format]);
82
+ return;
83
+ }
84
+ call.setRequest(GEN_AI.requestFrequencyPenalty, finite(body.frequency_penalty));
85
+ call.setRequest(GEN_AI.requestPresencePenalty, finite(body.presence_penalty));
86
+ call.setRequest(GEN_AI.requestSeed, int(body.seed));
87
+ const n = int(body.n);
88
+ if (n !== undefined && n !== 1)
89
+ call.setRequest(GEN_AI.requestChoiceCount, n);
90
+ // Índices de escolha acima de 127 não são acompanhados (`setFinishReason`): o teto mantém a conta possível.
91
+ if (n !== undefined && n >= 1)
92
+ call.expectedChoices = Math.min(n, 128);
93
+ const format = obj(body.response_format).type;
94
+ if (format === 'json_schema' || format === 'json_object')
95
+ call.setRequest(GEN_AI.outputType, 'json');
96
+ else if (format === 'text')
97
+ call.setRequest(GEN_AI.outputType, 'text');
98
+ }
99
+ /**
100
+ * Prende o estado GenAI ao span `http.client` de uma requisição reconhecida. Lê só o corpo em
101
+ * texto/bytes já em memória (o SDK da OpenAI e o da Anthropic mandam string); `Request` com corpo
102
+ * em stream não é lido, e o modelo sai da resposta.
103
+ */
104
+ export function startHttpGenAiCall(config, span, target, input, init, startedPerf = performance.now()) {
105
+ const text = requestBodyText(init?.body);
106
+ const body = text === undefined ? {} : obj(safeParse(text));
107
+ const call = new GenAiCall(span, config, target.provider, target.operation, startedPerf, modelName(body.model));
108
+ applyRequest(call, target.api, body);
109
+ // Retentativa dos clientes Stainless (OpenAI, Anthropic): cada tentativa é um `http.client` próprio.
110
+ const retry = Number(headerValue(input, init, RETRY_HEADER));
111
+ if (Number.isSafeInteger(retry) && retry > 0)
112
+ call.setRequest(GEN_AI.resendCount, retry);
113
+ return call;
114
+ }
115
+ /**
116
+ * A requisição falhou antes da resposta (rede, abort). Cancelamento não é erro: vale o `AbortError` e
117
+ * também o `signal` abortado com motivo próprio (`controller.abort(new Error(...))`), com que o
118
+ * `fetch` rejeita no lugar do `AbortError`.
119
+ */
120
+ export function failHttpGenAiCall(call, err, signal) {
121
+ if (isAbortError(err) || signal?.aborted === true) {
122
+ call.finish('cancelled');
123
+ return;
124
+ }
125
+ const cause = err?.cause;
126
+ const name = err?.name;
127
+ call.errorType = errorTypeOf(cause?.code) ?? (name === 'TimeoutError' ? 'timeout' : errorTypeOf(name)) ?? '_OTHER';
128
+ call.finish('error', err);
129
+ }
130
+ /* ------------------------------------------------------------------ */
131
+ /* Uso por formato */
132
+ /* ------------------------------------------------------------------ */
133
+ /** `usage` do Chat Completions/Embeddings (e do OpenRouter): `prompt_tokens` já inclui o cache. */
134
+ function openAiUsage(raw) {
135
+ const usage = obj(raw);
136
+ const prompt = obj(usage.prompt_tokens_details);
137
+ const completion = obj(usage.completion_tokens_details);
138
+ return {
139
+ input: tokenCount(usage.prompt_tokens),
140
+ output: tokenCount(usage.completion_tokens),
141
+ cacheRead: tokenCount(prompt.cached_tokens),
142
+ cacheCreation: tokenCount(prompt.cache_write_tokens),
143
+ reasoning: tokenCount(completion.reasoning_tokens),
144
+ };
145
+ }
146
+ /** `usage` da Anthropic: `input_tokens` não inclui o cache; a semconv manda somar (anthropic.md). */
147
+ function anthropicInput(usage) {
148
+ const input = tokenCount(usage.input_tokens);
149
+ const cacheRead = tokenCount(usage.cache_read_input_tokens);
150
+ const cacheCreation = tokenCount(usage.cache_creation_input_tokens);
151
+ return {
152
+ input: input === undefined ? undefined : input + (cacheRead ?? 0) + (cacheCreation ?? 0),
153
+ cacheRead,
154
+ cacheCreation,
155
+ };
156
+ }
157
+ function setUsage(call, usage) {
158
+ const merged = { ...call.usage };
159
+ for (const key of ['input', 'output', 'cacheRead', 'cacheCreation', 'reasoning']) {
160
+ if (usage[key] !== undefined)
161
+ merged[key] = usage[key];
162
+ }
163
+ call.usage = merged;
164
+ }
165
+ function providerError(call, error, stream) {
166
+ call.errorType = errorTypeOf(error.code) ?? errorTypeOf(error.type) ?? 'provider_error';
167
+ // Só o identificador do erro: a mensagem do provedor pode repetir trechos do pedido.
168
+ call.finish('error', { name: stream ? 'GenAIStreamError' : 'GenAIProviderError', message: `${call.provider}: ${call.errorType}` });
169
+ }
170
+ /** Corpo JSON inteiro de uma resposta 2xx. */
171
+ function applyJson(call, api, body) {
172
+ if (api === 'anthropic') {
173
+ if (body.type === 'error')
174
+ return providerError(call, obj(body.error), false);
175
+ call.setResponseId(body.id);
176
+ call.setResponseModel(body.model);
177
+ call.setFinishReason(0, body.stop_reason);
178
+ const usage = obj(body.usage);
179
+ setUsage(call, { ...anthropicInput(usage), output: tokenCount(usage.output_tokens) });
180
+ call.finish('completed');
181
+ return;
182
+ }
183
+ // OpenRouter devolve alguns erros do provedor como 200 `{ error }`.
184
+ if (body.error !== undefined && body.error !== null)
185
+ return providerError(call, obj(body.error), false);
186
+ call.setResponseId(body.id);
187
+ call.setResponseModel(body.model);
188
+ if (Array.isArray(body.choices)) {
189
+ body.choices.forEach((choice, position) => {
190
+ const item = obj(choice);
191
+ call.setFinishReason(int(item.index) ?? position, item.finish_reason);
192
+ });
193
+ }
194
+ if (body.usage !== undefined) {
195
+ setUsage(call, openAiUsage(body.usage));
196
+ call.reportedCost = reportedCost(call.provider, obj(body.usage));
197
+ }
198
+ call.finish('completed');
199
+ }
200
+ /**
201
+ * Resposta grande demais para analisar inteira: só `usage` e `model` **da raiz** do objeto, quando
202
+ * cabem no fim do corpo. Só é chamada quando `JsonShape` provou que o `}` final fecha a raiz. Chave de
203
+ * mesmo nome dentro de outro valor (argumentos de ferramenta, texto) nunca é lida; o que não dá para
204
+ * identificar com segurança fica sem atributo.
205
+ */
206
+ function applyTail(call, api, tail) {
207
+ const root = rootEntriesFromEnd(tail);
208
+ call.setResponseModel(root.model);
209
+ const usage = root.usage;
210
+ if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
211
+ const fields = usage;
212
+ if (api === 'anthropic')
213
+ setUsage(call, { ...anthropicInput(fields), output: tokenCount(fields.output_tokens) });
214
+ else {
215
+ setUsage(call, openAiUsage(fields));
216
+ call.reportedCost = reportedCost(call.provider, fields);
217
+ }
218
+ }
219
+ call.finish('completed');
220
+ }
221
+ /**
222
+ * Estado da posição `at`: 0 = não é `"` ou é `"` escapado; 1 = `"` que abre ou fecha string (número
223
+ * par de barras invertidas antes); 2 = indeterminado, porque as barras chegam ao começo do trecho e
224
+ * o que vinha antes foi cortado.
225
+ */
226
+ function quoteState(text, at) {
227
+ if (text.charCodeAt(at) !== 34)
228
+ return 0;
229
+ let k = at - 1;
230
+ let slashes = 0;
231
+ while (k >= 0 && text.charCodeAt(k) === 92) {
232
+ slashes += 1;
233
+ k -= 1;
234
+ }
235
+ if (k < 0)
236
+ return 2;
237
+ return slashes % 2 === 0 ? 1 : 0;
238
+ }
239
+ /** Posição da aspa que abre a string fechada em `close`, ou -1 se não der para saber dentro do trecho. */
240
+ function stringStart(text, close) {
241
+ for (let j = close - 1; j >= 0; j -= 1) {
242
+ const state = quoteState(text, j);
243
+ if (state === 1)
244
+ return j;
245
+ if (state === 2)
246
+ return -1;
247
+ }
248
+ return -1;
249
+ }
250
+ /**
251
+ * Pares chave → valor da **raiz** de um objeto JSON lidos de trás para frente a partir do fim do
252
+ * texto (o fim do corpo), sem precisar do começo. Lendo de trás para frente a partir do `}` final,
253
+ * sempre se sabe se a posição está dentro de uma string (cada `"` não escapado alterna) e a que
254
+ * profundidade: só pares na profundidade 1 entram. A leitura para no primeiro ponto que o corte do
255
+ * trecho deixa ambíguo; o que começa antes dele é ignorado.
256
+ */
257
+ export function rootEntriesFromEnd(text) {
258
+ const out = {};
259
+ let i = text.length - 1;
260
+ while (i >= 0 && /\s/.test(text[i]))
261
+ i -= 1;
262
+ if (text[i] !== '}')
263
+ return out;
264
+ let depth = 1;
265
+ let valueEnd = i; // posição do `,` ou `}` que fecha o valor corrente da raiz
266
+ i -= 1;
267
+ while (i >= 0) {
268
+ const quote = quoteState(text, i);
269
+ if (quote === 2)
270
+ return out;
271
+ if (quote === 1) {
272
+ // Pula a string inteira até a aspa que a abre.
273
+ const open = stringStart(text, i);
274
+ if (open < 0)
275
+ return out;
276
+ i = open - 1;
277
+ continue;
278
+ }
279
+ const ch = text.charCodeAt(i);
280
+ if (ch === 125 || ch === 93)
281
+ depth += 1;
282
+ else if (ch === 123 || ch === 91) {
283
+ depth -= 1;
284
+ if (depth === 0)
285
+ return out; // início da raiz
286
+ }
287
+ else if (depth === 1 && ch === 44)
288
+ valueEnd = i;
289
+ else if (depth === 1 && ch === 58) {
290
+ // `:` da raiz: a chave é a string logo antes; o valor vai até `valueEnd`.
291
+ let k = i - 1;
292
+ while (k >= 0 && /\s/.test(text[k]))
293
+ k -= 1;
294
+ if (k < 0 || quoteState(text, k) !== 1)
295
+ return out;
296
+ const open = stringStart(text, k);
297
+ if (open < 0)
298
+ return out;
299
+ const key = safeParse(text.slice(open, k + 1));
300
+ const value = safeParse(text.slice(i + 1, valueEnd));
301
+ if (typeof key === 'string' && value !== undefined && !Object.prototype.hasOwnProperty.call(out, key))
302
+ out[key] = value;
303
+ i = open - 1;
304
+ continue;
305
+ }
306
+ i -= 1;
307
+ }
308
+ return out;
309
+ }
310
+ /** Índices de escolha acompanhados (os mesmos de `setFinishReason`). */
311
+ const MAX_CHOICE_INDEX = 127;
312
+ function openAiStream(call) {
313
+ let first = true;
314
+ /** Índices de escolha que apareceram no stream e os que já trouxeram `finish_reason`. */
315
+ const started = new Set();
316
+ const finished = new Set();
317
+ return {
318
+ event({ data }) {
319
+ call.markFirstChunk();
320
+ if (data === '[DONE]') {
321
+ call.finish('completed');
322
+ return;
323
+ }
324
+ // Todo pedaço é lido (são pequenos): os índices das escolhas que começaram decidem se o fim sem
325
+ // `[DONE]` cortou alguma, mesmo quando o pedido (e o `n`) não pôde ser lido.
326
+ const chunk = obj(safeParse(data));
327
+ if (first) {
328
+ first = false;
329
+ call.setResponseId(chunk.id);
330
+ call.setResponseModel(chunk.model);
331
+ }
332
+ if (chunk.error !== undefined && chunk.error !== null)
333
+ return providerError(call, obj(chunk.error), true);
334
+ if (Array.isArray(chunk.choices)) {
335
+ for (const [position, choice] of chunk.choices.entries()) {
336
+ const item = obj(choice);
337
+ if (item.error !== undefined && item.error !== null)
338
+ return providerError(call, obj(item.error), true);
339
+ const index = int(item.index) ?? position;
340
+ const tracked = index >= 0 && index <= MAX_CHOICE_INDEX;
341
+ if (tracked)
342
+ started.add(index);
343
+ if (typeof item.finish_reason === 'string') {
344
+ if (tracked)
345
+ finished.add(index);
346
+ call.setFinishReason(index, item.finish_reason);
347
+ }
348
+ }
349
+ }
350
+ if (chunk.usage !== undefined && chunk.usage !== null) {
351
+ setUsage(call, openAiUsage(chunk.usage));
352
+ call.reportedCost = reportedCost(call.provider, obj(chunk.usage));
353
+ }
354
+ },
355
+ end() {
356
+ // Sem `[DONE]` (gateways que fecham o stream direto), só termina se todas as escolhas que
357
+ // começaram pararam e se pararam tantas quantas o pedido pediu (`n`, quando lido): com uma
358
+ // escolha cortada, a resposta veio truncada.
359
+ const allStopped = [...started].every((index) => finished.has(index));
360
+ call.finish(allStopped && finished.size >= call.expectedChoices ? 'completed' : 'incomplete');
361
+ },
362
+ };
363
+ }
364
+ function anthropicStream(call) {
365
+ let finalUsage = false;
366
+ const settle = () => {
367
+ // Sem o `message_delta` com o uso final, só a entrada do `message_start` é conhecida.
368
+ if (!finalUsage) {
369
+ call.usage = { input: call.usage.input, cacheRead: call.usage.cacheRead, cacheCreation: call.usage.cacheCreation };
370
+ call.usagePartial = call.usage.input !== undefined;
371
+ }
372
+ };
373
+ call.onFinish(settle);
374
+ return {
375
+ event({ event, data }) {
376
+ call.markFirstChunk();
377
+ // `content_block_delta` é a maior parte do stream e só traz texto: nem é analisado.
378
+ if (event !== 'message_start' && event !== 'message_delta' && event !== 'message_stop' && event !== 'error')
379
+ return;
380
+ const payload = obj(safeParse(data));
381
+ if (event === 'error')
382
+ return providerError(call, obj(payload.error), true);
383
+ if (event === 'message_stop') {
384
+ call.finish('completed');
385
+ return;
386
+ }
387
+ if (event === 'message_start') {
388
+ const message = obj(payload.message);
389
+ call.setResponseId(message.id);
390
+ call.setResponseModel(message.model);
391
+ setUsage(call, anthropicInput(obj(message.usage)));
392
+ return;
393
+ }
394
+ call.setFinishReason(0, obj(payload.delta).stop_reason);
395
+ const usage = obj(payload.usage);
396
+ const output = tokenCount(usage.output_tokens);
397
+ if (output !== undefined) {
398
+ finalUsage = true;
399
+ // Versões recentes repetem a entrada (acumulada) no `message_delta`.
400
+ const input = anthropicInput(usage);
401
+ setUsage(call, { ...(input.input !== undefined ? input : {}), output });
402
+ }
403
+ },
404
+ end() {
405
+ call.finish('incomplete');
406
+ },
407
+ };
408
+ }
409
+ function isWebResponse(res) {
410
+ return typeof Response === 'function' && res instanceof Response && !!res.body && typeof res.body.getReader === 'function';
411
+ }
412
+ /**
413
+ * Atributos que o construtor de `Response` não copia (`url`, `redirected`, `type`). O `clone()` nativo
414
+ * os tira do estado interno, que a `Response` nova não tem: o `clone()` dela também os copia, e o
415
+ * clone de um clone também.
416
+ */
417
+ function copyResponseIdentity(from, to) {
418
+ for (const key of ['url', 'redirected', 'type']) {
419
+ try {
420
+ Object.defineProperty(to, key, { value: from[key], enumerable: false, configurable: true });
421
+ }
422
+ catch {
423
+ /* best-effort */
424
+ }
425
+ }
426
+ const nativeClone = to.clone;
427
+ if (typeof nativeClone !== 'function')
428
+ return;
429
+ try {
430
+ Object.defineProperty(to, 'clone', {
431
+ value: function clone() {
432
+ const copy = nativeClone.call(this);
433
+ copyResponseIdentity(from, copy);
434
+ return copy;
435
+ },
436
+ enumerable: false,
437
+ configurable: true,
438
+ writable: true,
439
+ });
440
+ }
441
+ catch {
442
+ /* best-effort */
443
+ }
444
+ }
445
+ function guard(call, fn) {
446
+ if (call.done)
447
+ return;
448
+ try {
449
+ fn();
450
+ }
451
+ catch {
452
+ // Falha da observação nunca chega à aplicação: o span sai com o que já se sabia.
453
+ call.finish('unobserved');
454
+ }
455
+ }
456
+ /**
457
+ * `Response` equivalente à original (status, headers, `url`) cujo corpo repassa o da original sob
458
+ * demanda: cada leitura de quem chamou puxa um pedaço (`highWaterMark: 0`, nada lido antes) e
459
+ * cancelar cancela a original, que fecha a conexão como antes. O corpo é um byte stream, como o do
460
+ * `fetch`, e aceita também leitor BYOB (`getReader({ mode: 'byob' })`). Cada pedaço segue copiado:
461
+ * no byte stream o `enqueue` transfere o `ArrayBuffer`, e o da original pode ser compartilhado.
462
+ * Devolve `null` se não der para montar a `Response`.
463
+ */
464
+ function relayResponse(res, hooks) {
465
+ const source = res.body;
466
+ let reader;
467
+ const take = () => (reader ??= source.getReader());
468
+ const stream = new ReadableStream({
469
+ type: 'bytes',
470
+ async pull(controller) {
471
+ for (;;) {
472
+ let result;
473
+ try {
474
+ result = await take().read();
475
+ }
476
+ catch (err) {
477
+ hooks.error?.(err);
478
+ controller.error(err);
479
+ return;
480
+ }
481
+ if (result.done) {
482
+ hooks.end?.();
483
+ try {
484
+ controller.close();
485
+ // Leitura BYOB pendente só termina com `respond(0)` depois do `close()`.
486
+ controller.byobRequest?.respond(0);
487
+ }
488
+ catch {
489
+ /* já cancelado */
490
+ }
491
+ return;
492
+ }
493
+ // Pedaço vazio não pode entrar num byte stream: lê o próximo.
494
+ if (result.value.byteLength === 0)
495
+ continue;
496
+ hooks.chunk?.(result.value);
497
+ try {
498
+ controller.enqueue(new Uint8Array(result.value));
499
+ }
500
+ catch {
501
+ /* já cancelado */
502
+ }
503
+ return;
504
+ }
505
+ },
506
+ cancel(reason) {
507
+ hooks.cancel?.(reason);
508
+ return take().cancel(reason);
509
+ },
510
+ });
511
+ let wrapped;
512
+ try {
513
+ wrapped = new Response(stream, { status: res.status, statusText: res.statusText, headers: res.headers });
514
+ }
515
+ catch {
516
+ return null;
517
+ }
518
+ copyResponseIdentity(res, wrapped);
519
+ return wrapped;
520
+ }
521
+ /**
522
+ * Corpo 2xx observado no caminho da leitura de quem chamou (`relayResponse`): nada é lido antes da
523
+ * aplicação, e a contrapressão da conexão continua a mesma. Corpo que ninguém lê fica sem
524
+ * observação e o prazo `observationIdleMs` fecha o span como `unobserved`; cancelar o corpo encerra
525
+ * o span como `cancelled`.
526
+ */
527
+ function observeBody(call, res, observer, signal) {
528
+ const wrapped = relayResponse(res, {
529
+ chunk(bytes) {
530
+ if (call.done)
531
+ return;
532
+ call.markActivity();
533
+ guard(call, () => observer.chunk(bytes));
534
+ },
535
+ end() {
536
+ guard(call, () => observer.end());
537
+ },
538
+ error(err) {
539
+ // Abort com motivo próprio faz o corpo rejeitar com esse motivo, não com `AbortError`.
540
+ guard(call, () => (isAbortError(err) || signal?.aborted === true ? call.finish('cancelled') : failStream(call, err)));
541
+ },
542
+ cancel() {
543
+ guard(call, () => call.finish('cancelled'));
544
+ },
545
+ });
546
+ if (!wrapped) {
547
+ call.finish('unobserved');
548
+ return res;
549
+ }
550
+ return wrapped;
551
+ }
552
+ /** Stream SSE: os bytes viram eventos para o observador do formato. */
553
+ function sseObserver(call, observer) {
554
+ const decoder = new TextDecoder();
555
+ const parser = new SseParser((event) => guard(call, () => observer.event(event)));
556
+ return {
557
+ chunk(bytes) {
558
+ parser.feed(decoder.decode(bytes, { stream: true }));
559
+ },
560
+ end() {
561
+ parser.feed(decoder.decode());
562
+ parser.end();
563
+ observer.end();
564
+ },
565
+ };
566
+ }
567
+ /**
568
+ * Corpo JSON: guarda uma cópia até `MAX_RESPONSE_PARSE_BYTES` (os bytes são copiados: quem chamou pode
569
+ * transferir os seus); acima disso, só o fim (`RESPONSE_TAIL_BYTES`), lido pelas chaves da raiz.
570
+ */
571
+ function jsonObserver(call, api) {
572
+ const chunks = [];
573
+ let kept = 0;
574
+ let overflow = false;
575
+ const shape = new JsonShape();
576
+ return {
577
+ chunk(bytes) {
578
+ shape.push(bytes);
579
+ chunks.push(bytes.slice());
580
+ kept += bytes.byteLength;
581
+ if (!overflow && kept > MAX_RESPONSE_PARSE_BYTES)
582
+ overflow = true;
583
+ // Só o fim interessa: descarta do começo mantendo pelo menos `RESPONSE_TAIL_BYTES`.
584
+ if (overflow)
585
+ while (chunks.length > 1 && kept - chunks[0].byteLength >= RESPONSE_TAIL_BYTES)
586
+ kept -= chunks.shift().byteLength;
587
+ },
588
+ end() {
589
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
590
+ chunks.length = 0;
591
+ // Corpo que não é um objeto JSON inteiro (cortado pelo gateway, lixo depois da raiz): a resposta
592
+ // não serve, e nada dela vira atributo. No fim de um corpo grande, o `}` final só é da raiz se a
593
+ // estrutura acompanhada desde o primeiro byte o provar.
594
+ if (!shape.complete)
595
+ return call.finish('incomplete');
596
+ if (overflow)
597
+ return applyTail(call, api, text);
598
+ const parsed = safeParse(text);
599
+ if (parsed === undefined || parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
600
+ return call.finish('incomplete');
601
+ applyJson(call, api, obj(parsed));
602
+ },
603
+ };
604
+ }
605
+ /**
606
+ * Estrutura de um corpo JSON acompanhada byte a byte desde o início, sem guardar o corpo: profundidade
607
+ * de `{`/`[` fora de strings (cada `"` não escapado alterna; `\` escapa o byte seguinte). Os bytes que
608
+ * importam (`"`, `\`, chaves e colchetes) são ASCII e nunca aparecem dentro de um caractere UTF-8 de
609
+ * vários bytes, então os pedaços podem ser lidos como chegam, partidos em qualquer ponto.
610
+ * `complete` = o corpo é um objeto (`{` como primeiro byte não branco) cuja raiz fecha uma vez, no
611
+ * fim, com no máximo espaço em branco depois.
612
+ */
613
+ export class JsonShape {
614
+ depth = 0;
615
+ inString = false;
616
+ escaped = false;
617
+ started = false;
618
+ closed = false;
619
+ broken = false;
620
+ push(bytes) {
621
+ if (this.broken)
622
+ return;
623
+ for (let i = 0; i < bytes.length; i += 1) {
624
+ const b = bytes[i];
625
+ if (this.inString) {
626
+ if (this.escaped)
627
+ this.escaped = false;
628
+ else if (b === 0x5c)
629
+ this.escaped = true;
630
+ else if (b === 0x22)
631
+ this.inString = false;
632
+ continue;
633
+ }
634
+ // Espaço, tab, LF, CR: permitidos em qualquer lugar fora de strings, inclusive depois da raiz.
635
+ if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d)
636
+ continue;
637
+ if (this.closed || (!this.started && b !== 0x7b)) {
638
+ this.broken = true;
639
+ return;
640
+ }
641
+ this.started = true;
642
+ if (b === 0x22)
643
+ this.inString = true;
644
+ else if (b === 0x7b || b === 0x5b)
645
+ this.depth += 1;
646
+ else if (b === 0x7d || b === 0x5d) {
647
+ this.depth -= 1;
648
+ if (this.depth === 0)
649
+ this.closed = true;
650
+ }
651
+ }
652
+ }
653
+ get complete() {
654
+ return !this.broken && this.closed && !this.inString;
655
+ }
656
+ }
657
+ function failStream(call, err) {
658
+ const name = err?.name;
659
+ call.errorType = errorTypeOf(err?.cause?.code) ?? errorTypeOf(name) ?? '_OTHER';
660
+ call.finish('error', err);
661
+ }
662
+ /** Lê a cópia do corpo de erro até `limit` bytes; acima disso para (e cancela a cópia). */
663
+ async function readCopy(call, reader, limit) {
664
+ const chunks = [];
665
+ let total = 0;
666
+ for (;;) {
667
+ const { done, value } = await reader.read();
668
+ if (done)
669
+ break;
670
+ // Corpo ainda chegando: o prazo de `observationIdleMs` conta a partir da última leitura, como no stream.
671
+ call.markActivity();
672
+ total += value.byteLength;
673
+ if (total > limit) {
674
+ await reader.cancel().catch(() => undefined);
675
+ break;
676
+ }
677
+ chunks.push(value);
678
+ }
679
+ return new TextDecoder().decode(Buffer.concat(chunks));
680
+ }
681
+ /**
682
+ * Cópia do corpo de um erro HTTP sem mudar o que quem chamou pode fazer com o seu: `clone()` divide o
683
+ * corpo em dois ramos, e o cancelamento de um ramo só chega à conexão quando o outro também cancela.
684
+ * Por isso a `Response` devolvida tem o corpo original repassado sob demanda, e cancelá-lo cancela
685
+ * também a cópia depois de no máximo `ERROR_BODY_GRACE_MS` (para ler o código do provedor), ou na
686
+ * hora se o span já fechou: a conexão fecha como sem instrumentação. Se o span fecha durante a
687
+ * tolerância, a cópia cancela junto. Sem `clone()` disponível, devolve `null`.
688
+ */
689
+ function observedCopy(call, res) {
690
+ let copy;
691
+ try {
692
+ copy = res.clone();
693
+ }
694
+ catch {
695
+ return null;
696
+ }
697
+ const copyReader = copy.body.getReader();
698
+ const stopCopy = () => void copyReader.cancel().catch(() => undefined);
699
+ // O cancelamento da aplicação só chega à conexão quando a cópia também cancela. Ela cancela no fim
700
+ // da tolerância ou quando o span fecha (código lido, prazo sem leitura, abort), o que vier antes:
701
+ // o fim do span nunca desarma esse cancelamento. Sem cancelamento da aplicação, a cópia segue
702
+ // até o fim do corpo ou o limite, presa à conexão que a aplicação ainda mantém.
703
+ let appCancelled = false;
704
+ call.onFinish(() => {
705
+ if (appCancelled)
706
+ stopCopy();
707
+ });
708
+ const wrapped = relayResponse(res, {
709
+ cancel() {
710
+ appCancelled = true;
711
+ if (call.done)
712
+ return stopCopy();
713
+ const timer = setTimeout(stopCopy, ERROR_BODY_GRACE_MS);
714
+ if (typeof timer === 'object' && timer && 'unref' in timer)
715
+ timer.unref();
716
+ },
717
+ });
718
+ if (!wrapped) {
719
+ void stopCopy();
720
+ return null;
721
+ }
722
+ return { response: wrapped, copy: copyReader };
723
+ }
724
+ /**
725
+ * HTTP ≥ 400: o desfecho já é `error` (nem o prazo sem leitura, nem abort, nem cancelar o corpo o
726
+ * trocam); `error.type` = código do provedor no corpo, ou o status. 5xx já vem com `span.error` do
727
+ * `fetch`. O corpo é lido numa cópia limitada a `MAX_ERROR_BODY_BYTES` (os clientes OpenAI/Anthropic
728
+ * cancelam o corpo sem lê-lo antes de tentar de novo), então o que a cópia adianta da conexão também
729
+ * fica nesse limite.
730
+ */
731
+ function observeError(call, res) {
732
+ call.errorType = String(res.status);
733
+ call.failed = true;
734
+ const observed = isWebResponse(res) ? observedCopy(call, res) : null;
735
+ if (!observed) {
736
+ call.finish('error');
737
+ return res;
738
+ }
739
+ call.armWatchdog();
740
+ readCopy(call, observed.copy, MAX_ERROR_BODY_BYTES).then((text) => guard(call, () => {
741
+ const body = obj(safeParse(text));
742
+ const error = obj(body.error);
743
+ const type = errorTypeOf(error.code) ?? errorTypeOf(error.type);
744
+ if (type)
745
+ call.errorType = type;
746
+ call.finish('error');
747
+ }), () => guard(call, () => call.finish('error')));
748
+ return observed.response;
749
+ }
750
+ /**
751
+ * Observa a resposta e devolve a que vai para quem chamou: uma equivalente cujo corpo é repassado
752
+ * sob demanda e observado no caminho (JSON e stream SSE, sem leitura adiantada; erro HTTP numa cópia
753
+ * limitada), ou a própria quando não dá para observar. O span fecha quando a observação termina.
754
+ */
755
+ export function observeHttpGenAiResponse(call, res, api, signal) {
756
+ try {
757
+ if (signal?.aborted === true) {
758
+ // Abortado entre os headers e a devolução da `Response` (um `fetch` intermediário que ainda fez
759
+ // algo depois dos headers): o corpo vai rejeitar, e quem chamou desistiu. Num 4xx/5xx o status
760
+ // já decidiu (`failed` mantém `error`).
761
+ if (res.status >= 400) {
762
+ call.errorType = String(res.status);
763
+ call.failed = true;
764
+ }
765
+ call.finish('cancelled');
766
+ return res;
767
+ }
768
+ if (signal && typeof signal.addEventListener === 'function') {
769
+ const onAbort = () => guard(call, () => call.finish('cancelled'));
770
+ signal.addEventListener('abort', onAbort, { once: true });
771
+ call.onFinish(() => signal.removeEventListener('abort', onAbort));
772
+ }
773
+ if (res.status >= 400)
774
+ return observeError(call, res);
775
+ if (!res.body) {
776
+ call.finish('completed');
777
+ return res;
778
+ }
779
+ if (!isWebResponse(res)) {
780
+ call.finish('unobserved');
781
+ return res;
782
+ }
783
+ const type = (res.headers.get('content-type') ?? '').toLowerCase();
784
+ const observer = type.includes('text/event-stream')
785
+ ? sseObserver(call, call.operation === 'chat' && api === 'anthropic' ? anthropicStream(call) : openAiStream(call))
786
+ : jsonObserver(call, api);
787
+ call.armWatchdog();
788
+ return observeBody(call, res, observer, signal);
789
+ }
790
+ catch {
791
+ call.finish('unobserved');
792
+ return res;
793
+ }
794
+ }
795
+ //# sourceMappingURL=http.js.map