(dataContent)
| 255 | return await work() |
| 256 | } finally { |
| 257 | clearInterval(heartbeat) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | const runWithSSEHeartbeat = async (res, work, intervalMs = 15000) => { |
| 262 | const heartbeatMs = Math.max(1, Number(intervalMs) || 15000) |
| 263 | const heartbeat = setInterval(() => { |
| 264 | if (res.writableEnded || res.destroyed) return |
| 265 | try { |
| 266 | // SSE 已经提交 200 响应后,用注释帧保活。注释不会进入 OpenAI delta, |
| 267 | // 但能阻止反代在长 thinking 或纠正 attempt 期间把连接判为空闲。 |
| 268 | res.write(': qwen2api-agent-keepalive\n\n') |
| 269 | if (typeof res.flush === 'function') res.flush() |
| 270 | } catch (_) { |
| 271 | // 客户端断开会由后续流消费/写入路径统一收敛。 |
| 272 | } |
| 273 | }, heartbeatMs) |
| 274 | heartbeat.unref?.() |
| 275 | try { |
| 276 | return await work() |
| 277 | } finally { |
| 278 | clearInterval(heartbeat) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | const normalizeAgentUsage = (attempt, requestBody, completionText) => { |
| 283 | let usage = { ...(attempt?.totalTokens || {}) } |
| 284 | if (!usage.prompt_tokens && !usage.completion_tokens) { |
| 285 | usage = createUsageObject(requestBody?.messages || [], completionText, null) |
| 286 | } |
| 287 | usage.prompt_tokens = Math.max(0, Number(usage.prompt_tokens) || 0) |
| 288 | usage.completion_tokens = Math.max(0, Number(usage.completion_tokens) || 0) |
| 289 | usage.total_tokens = usage.prompt_tokens + usage.completion_tokens |
| 290 | return usage |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Residuo de protocolo que TODAVÍA se puede pelar en la entrega. |
| 295 | * |
| 296 | * Lo que ya salió en vivo por el canal de contenido es irrecuperable, y borrarlo del buffer |
| 297 | * rompería el descuento de handleOpenAIAgentStream (`bufferedContent.startsWith(...)`) y lo |
| 298 | * duplicaría en el cliente: un residuo entregado una vez es mejor que la respuesta entera |
| 299 | * entregada dos. Hoy ninguna ronda aceptada llega aquí con texto ya emitido y residuo a la |
| 300 | * vez (el gate 422 corta antes), así que este filtro es defensa, no un camino vivo. |
| 301 | */ |
| 302 | const deliverableResidueSpans = (attempt, alreadyStreamed = 0) => |
| 303 | (attempt?.residueSpans || []).filter(span => |
| 304 | span && typeof span.text === 'string' && Number.isInteger(span.at) && span.at >= alreadyStreamed) |
| 305 | |
| 306 | /** |
| 307 | * Pelado de ENTREGA, gemelo literal de anthropic.js:2278. |
| 308 | * |
| 309 | * Orden obligatorio: primero el residuo por POSICIÓN —sobre el texto crudo, que es el |
| 310 | * sistema de coordenadas en el que el parser registró los spans— y sólo después las |
| 311 | * etiquetas de control. Al revés, quitar las etiquetas desplazaría los offsets y el residuo |
| 312 | * sobreviviría (lo pinta el gemelo en anthropic-toolcall-salvage: "strip-before-tags keeps |
| 313 | * offsets honest"). |
| 314 | * |
no test coverage detected