From 9fb5056bb3b8a043c92be992457c6ddd60ed3230 Mon Sep 17 00:00:00 2001 From: Mafra Date: Wed, 29 Jul 2026 20:50:41 -0300 Subject: [PATCH 1/2] feat(shim): mostra aviso "preparando" em resposta lenta / cold start Quando o backend OSS esta lento (cold start ou preempcao do vLLM) e nao emite o sinal router_status:"warming", a sessao ficava parada e muda, e o usuario achava que travou (medido: glm chegou a 97s sem feedback). Agora um cronometro iniciado no ENVIO da requisicao mostra o mesmo estado de warming-up (spinner "...VerbooCodar!!") apos VERBOO_SLOW_HINT_MS (default 12s). Por iniciar no envio (e nao na leitura do corpo), cobre os dois casos: resposta lenta com a conexao ja aberta, e requisicao pendurada antes de qualquer resposta. O aviso some assim que o primeiro conteudo real chega, no fim do stream, em erro ou no abort. E so comunicacao visual: nao altera retry, nao aborta, nao muda o resultado. Testes (src/services/api/openaiShim.warmingHint.test.ts, 3 casos): 1. Corpo lento: stream que atrasa o 1o chunk. Verifica que 'warming-up' e emitido e depois limpo (null) ao chegar conteudo. 2. Requisicao pendurada: o proprio fetch demora a resolver. Verifica que 'warming-up' e emitido mesmo assim (cronometro conta desde o envio). 3. Resposta rapida: chunk chega antes do limiar. Verifica que 'warming-up' NAO e emitido (sem flicker). Regressao openaiShim*.test.ts: 117 pass. bun run build ok. tsc 0 erros novos. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/screens/REPL.tsx | 5 + src/services/api/openaiShim.ts | 94 +++++++++-- .../api/openaiShim.warmingHint.test.ts | 147 ++++++++++++++++++ 3 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/services/api/openaiShim.warmingHint.test.ts diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 2000c762e4..4975edc8d0 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1540,6 +1540,11 @@ export function REPL({ '🎯 Verboo Intelligence is locking onto the target', '🎬 Verboo Intelligence is setting the scene', '🎪 Verboo Intelligence is rolling out the red carpet', + '🔥 Getting ready to VerbooCodar!!', + '🚀 Warming up to VerbooCodar!', + '⚡ Firing up the engines to VerbooCodar!', + '🧠 Almost ready to VerbooCodar!', + '✨ Loading up to VerbooCodar!', ]; let cancelled = false; void import('../services/api/openaiShim.js').then(mod => { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9417f26eb2..7048acbf2b 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1053,6 +1053,56 @@ export function setOpenAIShimRouterStatusHandler( routerStatusHandler = fn } +// ----------------------------------------------------------------------------- +// Aviso "preparando o modelo" (warming hint) por TEMPO. +// Cobre a latência de cauda (cold start / preempção do backend) que NÃO vem +// acompanhada do sinal router_status:"warming". O cronômetro começa no ENVIO +// da requisição (em `create`), então pega tanto o caso do corpo lento quanto o +// da requisição inteira pendurada antes de qualquer resposta. É só comunicação +// visual — nunca aborta nem interfere no resultado. Override: VERBOO_SLOW_HINT_MS. +// Module-level porque routerStatusHandler também é (uma query ativa por vez). +let warmingHintTimer: ReturnType | null = null +let warmingHintShown = false + +function getSlowHintMs(): number { + const raw = process.env.VERBOO_SLOW_HINT_MS + const v = raw ? parseInt(raw, 10) : NaN + return Number.isFinite(v) && v > 0 ? v : 12_000 +} +function clearWarmingHintTimer(): void { + if (warmingHintTimer !== null) { + clearTimeout(warmingHintTimer) + warmingHintTimer = null + } +} +/** Arma o cronômetro; ao expirar sem conteúdo, mostra o aviso. */ +function scheduleWarmingHint(delayMs: number): void { + clearWarmingHintTimer() + warmingHintTimer = setTimeout(() => { + warmingHintTimer = null + if (!warmingHintShown) { + warmingHintShown = true + routerStatusHandler?.('warming-up') + } + }, delayMs) +} +/** Mostra o aviso imediatamente (usado quando o servidor sinaliza 'warming'). */ +function showWarmingHintNow(): void { + clearWarmingHintTimer() + if (!warmingHintShown) { + warmingHintShown = true + routerStatusHandler?.('warming-up') + } +} +/** Chegou conteúdo real / stream terminou / deu erro: limpa o aviso. Idempotente. */ +function resolveWarmingHint(): void { + clearWarmingHintTimer() + if (warmingHintShown) { + warmingHintShown = false + routerStatusHandler?.(null) + } +} + async function* openaiStreamToAnthropic( response: Response, model: string, @@ -1078,7 +1128,8 @@ async function* openaiStreamToAnthropic( let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false let hasProcessedFinishReason = false - let routerWarmingActive = false + // (aviso "preparando" agora é controlado no nível do módulo — ver + // scheduleWarmingHint/showWarmingHintNow/resolveWarmingHint acima) const streamState = createStreamState() // Emit message_start @@ -1117,6 +1168,7 @@ async function* openaiStreamToAnthropic( let lastDataTime = Date.now() const streamStartedAt = Date.now() + /** * Read from the stream with an idle timeout. If no data arrives within * STREAM_IDLE_TIMEOUT_MS, assume the connection is dead and throw so @@ -1249,9 +1301,10 @@ async function* openaiStreamToAnthropic( const routerStatus = (chunk as unknown as { router_status?: string }) .router_status if (typeof routerStatus === 'string') { - if (routerStatus === 'warming' && !routerWarmingActive) { - routerWarmingActive = true - routerStatusHandler?.('warming-up') + if (routerStatus === 'warming') { + // Servidor sinalizou cold start: mostra o aviso já (não espera o + // cronômetro por tempo). + showWarmingHintNow() } continue } @@ -1304,14 +1357,10 @@ async function* openaiStreamToAnthropic( const chunkUsage = convertChunkUsage(chunk.usage) - // Chunk com content real chegou — limpa warming-up se estava ativo. - if ( - routerWarmingActive && - Array.isArray(chunk.choices) && - chunk.choices.length > 0 - ) { - routerWarmingActive = false - routerStatusHandler?.(null) + // Chunk com content real chegou — limpa o aviso "preparando" (venha do + // sinal router_status OU do cronômetro por tempo). + if (Array.isArray(chunk.choices) && chunk.choices.length > 0) { + resolveWarmingHint() } for (const choice of chunk.choices ?? []) { @@ -1678,6 +1727,9 @@ async function* openaiStreamToAnthropic( } } } finally { + // Garante que o aviso "preparando" não vaze visualmente se o stream + // terminar (fim, erro ou abort) sem ter chegado conteúdo real. + resolveWarmingHint() reader.releaseLock() } @@ -1845,7 +1897,19 @@ class OpenAIShimMessages { reasoningEffortOverride: self.reasoningEffort, suppressReasoningEffort: self.suppressReasoningEffort, }) - const response = await self._doRequest(request, params, options) + // Cronômetro do aviso "preparando" começa AQUI (no envio da requisição), + // então cobre tanto a resposta lenta quanto a requisição pendurada antes + // de qualquer resposta. É resolvido: no gerador de streaming (quando o + // conteúdo chega ou o stream termina), logo abaixo nos caminhos + // não-stream, ou aqui no catch se a própria requisição falhar. + scheduleWarmingHint(getSlowHintMs()) + let response: Response + try { + response = await self._doRequest(request, params, options) + } catch (e) { + resolveWarmingHint() + throw e + } httpResponse = response if (params.stream) { @@ -1867,6 +1931,10 @@ class OpenAIShimMessages { ) } + // Caminhos não-stream: a resposta HTTP já chegou, então o aviso cumpriu + // seu papel — limpa antes de coletar/converter o corpo. + resolveWarmingHint() + if (request.transport === 'codex_responses') { const data = await collectCodexCompletedResponse( response, diff --git a/src/services/api/openaiShim.warmingHint.test.ts b/src/services/api/openaiShim.warmingHint.test.ts new file mode 100644 index 0000000000..1420193055 --- /dev/null +++ b/src/services/api/openaiShim.warmingHint.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + createOpenAIShimClient, + setOpenAIShimRouterStatusHandler, +} from './openaiShim.ts' + +type FetchType = typeof globalThis.fetch + +const originalFetch = globalThis.fetch +const originalEnv = { + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + VERBOO_SLOW_HINT_MS: process.env.VERBOO_SLOW_HINT_MS, +} + +beforeEach(() => { + process.env.OPENAI_BASE_URL = 'http://example.test/v1' + process.env.OPENAI_API_KEY = 'test-key' +}) + +afterEach(() => { + globalThis.fetch = originalFetch + setOpenAIShimRouterStatusHandler(null) + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } +}) + +const encoder = new TextEncoder() + +// SSE response que ATRASA a emissão do primeiro chunk por `delayMs`, simulando +// um backend lento (cold start / preempção) que fica em silêncio antes de +// começar a responder — sem emitir o sinal router_status:"warming". +function makeDelayedSseResponse(lines: string[], delayMs: number): Response { + let i = 0 + return new Response( + new ReadableStream({ + async pull(controller) { + if (i === 0 && delayMs > 0) { + await new Promise((r) => setTimeout(r, delayMs)) + } + if (i < lines.length) { + controller.enqueue(encoder.encode(lines[i]!)) + i++ + } else { + controller.close() + } + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ) +} + +function contentChunks(): string[] { + return [ + `data: ${JSON.stringify({ + id: 'c1', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: { role: 'assistant', content: 'oi' }, finish_reason: null }], + })}\n\n`, + `data: ${JSON.stringify({ + id: 'c1', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + })}\n\n`, + 'data: [DONE]\n\n', + ] +} + +async function drain(model = 'fake-model'): Promise { + const client = createOpenAIShimClient({}) as { + beta: { + messages: { + create: (p: Record) => { + withResponse: () => Promise<{ data: AsyncIterable }> + } + } + } + } + const result = await client.beta.messages + .create({ + model, + system: 'test', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: true, + }) + .withResponse() + for await (const _ of result.data) { + // consome o stream até o fim + } +} + +test('mostra warming-up por TEMPO quando o backend fica lento sem sinal do servidor', async () => { + // limiar curto pra teste; primeiro chunk atrasa mais que o limiar + process.env.VERBOO_SLOW_HINT_MS = '40' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler((s) => statusCalls.push(s)) + + globalThis.fetch = (async () => + makeDelayedSseResponse(contentChunks(), 150)) as unknown as FetchType + + await drain() + + // disparou o aviso 'warming-up' e depois limpou (null) ao chegar conteúdo + expect(statusCalls).toContain('warming-up') + expect(statusCalls[statusCalls.length - 1]).toBe(null) + const firstWarm = statusCalls.indexOf('warming-up') + const firstNull = statusCalls.indexOf(null) + expect(firstWarm).toBeGreaterThanOrEqual(0) + expect(firstNull).toBeGreaterThan(firstWarm) +}) + +test('mostra warming-up quando a REQUISIÇÃO fica pendurada antes de qualquer resposta (caso B)', async () => { + // Aqui o atraso está no próprio fetch (a resposta HTTP nem começa) — o + // cronômetro precisa contar desde o ENVIO, não só da leitura do corpo. + process.env.VERBOO_SLOW_HINT_MS = '40' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler((s) => statusCalls.push(s)) + + globalThis.fetch = (async () => { + await new Promise((r) => setTimeout(r, 150)) // requisição pendurada + return makeDelayedSseResponse(contentChunks(), 0) + }) as unknown as FetchType + + await drain() + + expect(statusCalls).toContain('warming-up') + expect(statusCalls[statusCalls.length - 1]).toBe(null) +}) + +test('NÃO mostra warming-up quando a resposta chega rápido (sem flicker)', async () => { + // limiar alto; chunk chega imediatamente + process.env.VERBOO_SLOW_HINT_MS = '5000' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler((s) => statusCalls.push(s)) + + globalThis.fetch = (async () => + makeDelayedSseResponse(contentChunks(), 0)) as unknown as FetchType + + await drain() + + expect(statusCalls).not.toContain('warming-up') +}) From d432becfe5943009c38cf12a6fbc7edb146dce26 Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Thu, 30 Jul 2026 14:09:30 -0300 Subject: [PATCH 2/2] fix(shim): scope warming hints per request --- src/services/api/openaiShim.ts | 189 +++++++++++++----- .../api/openaiShim.warmingHint.test.ts | 120 +++++++++++ 2 files changed, 260 insertions(+), 49 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7048acbf2b..af85371aec 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1046,10 +1046,17 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null { // e limpo no fim. Module-level (não per-instance) porque o cliente é criado // uma vez por sessão mas setSDKStatus muda a cada query. let routerStatusHandler: ((status: 'warming-up' | null) => void) | null = null +let routerStatusHandlerGeneration = 0 +const activeWarmingHints = new Set() export function setOpenAIShimRouterStatusHandler( fn: ((status: 'warming-up' | null) => void) | null, ): void { + if (routerStatusHandler && activeWarmingHints.size > 0) { + routerStatusHandler(null) + } + activeWarmingHints.clear() + routerStatusHandlerGeneration++ routerStatusHandler = fn } @@ -1060,53 +1067,94 @@ export function setOpenAIShimRouterStatusHandler( // da requisição (em `create`), então pega tanto o caso do corpo lento quanto o // da requisição inteira pendurada antes de qualquer resposta. É só comunicação // visual — nunca aborta nem interfere no resultado. Override: VERBOO_SLOW_HINT_MS. -// Module-level porque routerStatusHandler também é (uma query ativa por vez). -let warmingHintTimer: ReturnType | null = null -let warmingHintShown = false function getSlowHintMs(): number { const raw = process.env.VERBOO_SLOW_HINT_MS const v = raw ? parseInt(raw, 10) : NaN return Number.isFinite(v) && v > 0 ? v : 12_000 } -function clearWarmingHintTimer(): void { - if (warmingHintTimer !== null) { - clearTimeout(warmingHintTimer) - warmingHintTimer = null - } -} -/** Arma o cronômetro; ao expirar sem conteúdo, mostra o aviso. */ -function scheduleWarmingHint(delayMs: number): void { - clearWarmingHintTimer() - warmingHintTimer = setTimeout(() => { - warmingHintTimer = null - if (!warmingHintShown) { - warmingHintShown = true - routerStatusHandler?.('warming-up') - } - }, delayMs) + +type WarmingHintController = { + schedule: (delayMs: number) => void + showNow: () => void + resolve: () => void } -/** Mostra o aviso imediatamente (usado quando o servidor sinaliza 'warming'). */ -function showWarmingHintNow(): void { - clearWarmingHintTimer() - if (!warmingHintShown) { - warmingHintShown = true - routerStatusHandler?.('warming-up') + +/** + * One controller per request. The module-level set only aggregates visible + * hints so one completed request cannot clear another concurrent slow request. + */ +function createWarmingHintController( + signal?: AbortSignal, +): WarmingHintController { + const token = Symbol('warming-hint') + const handlerGeneration = routerStatusHandlerGeneration + let timer: ReturnType | null = null + let shown = false + let resolved = false + + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } } -} -/** Chegou conteúdo real / stream terminou / deu erro: limpa o aviso. Idempotente. */ -function resolveWarmingHint(): void { - clearWarmingHintTimer() - if (warmingHintShown) { - warmingHintShown = false - routerStatusHandler?.(null) + + const showNow = () => { + clearTimer() + if ( + resolved || + shown || + handlerGeneration !== routerStatusHandlerGeneration || + !routerStatusHandler + ) { + return + } + shown = true + const wasEmpty = activeWarmingHints.size === 0 + activeWarmingHints.add(token) + if (wasEmpty) routerStatusHandler('warming-up') } + + const resolve = () => { + if (resolved) return + resolved = true + clearTimer() + signal?.removeEventListener('abort', resolve) + if (!shown) return + + activeWarmingHints.delete(token) + if ( + activeWarmingHints.size === 0 && + handlerGeneration === routerStatusHandlerGeneration + ) { + routerStatusHandler?.(null) + } + } + + const schedule = (delayMs: number) => { + if (resolved) return + clearTimer() + timer = setTimeout(() => { + timer = null + showNow() + }, delayMs) + } + + if (signal?.aborted) { + resolved = true + } else { + signal?.addEventListener('abort', resolve, { once: true }) + } + + return { schedule, showNow, resolve } } async function* openaiStreamToAnthropic( response: Response, model: string, signal?: AbortSignal, + warmingHint?: WarmingHintController, ): AsyncGenerator { const messageId = makeMessageId() let contentBlockIndex = 0 @@ -1128,8 +1176,6 @@ async function* openaiStreamToAnthropic( let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false let hasProcessedFinishReason = false - // (aviso "preparando" agora é controlado no nível do módulo — ver - // scheduleWarmingHint/showWarmingHintNow/resolveWarmingHint acima) const streamState = createStreamState() // Emit message_start @@ -1304,7 +1350,7 @@ async function* openaiStreamToAnthropic( if (routerStatus === 'warming') { // Servidor sinalizou cold start: mostra o aviso já (não espera o // cronômetro por tempo). - showWarmingHintNow() + warmingHint?.showNow() } continue } @@ -1357,12 +1403,6 @@ async function* openaiStreamToAnthropic( const chunkUsage = convertChunkUsage(chunk.usage) - // Chunk com content real chegou — limpa o aviso "preparando" (venha do - // sinal router_status OU do cronômetro por tempo). - if (Array.isArray(chunk.choices) && chunk.choices.length > 0) { - resolveWarmingHint() - } - for (const choice of chunk.choices ?? []) { const delta = choice.delta @@ -1727,9 +1767,6 @@ async function* openaiStreamToAnthropic( } } } finally { - // Garante que o aviso "preparando" não vaze visualmente se o stream - // terminar (fim, erro ou abort) sem ter chegado conteúdo real. - resolveWarmingHint() reader.releaseLock() } @@ -1758,13 +1795,64 @@ class OpenAIShimStream { private generator: AsyncGenerator // The controller property is checked by claude.ts to distinguish streams from error messages controller = new AbortController() + private warmingHint?: WarmingHintController + private unconsumedCleanupTimer: ReturnType | null - constructor(generator: AsyncGenerator) { + constructor( + generator: AsyncGenerator, + warmingHint?: WarmingHintController, + ) { this.generator = generator + this.warmingHint = warmingHint + // The normal caller starts iterating immediately after awaiting + // withResponse(). If it abandons the returned stream, dispose the hint on + // the next event-loop turn so no status can leak into a later query. + this.unconsumedCleanupTimer = warmingHint + ? setTimeout(() => { + this.unconsumedCleanupTimer = null + warmingHint.resolve() + }, 0) + : null + } + + private markConsumed(): void { + if (this.unconsumedCleanupTimer !== null) { + clearTimeout(this.unconsumedCleanupTimer) + this.unconsumedCleanupTimer = null + } + } + + private hasMeaningfulOutput(event: AnthropicStreamEvent): boolean { + if ( + event.type === 'content_block_start' && + event.content_block?.type === 'tool_use' + ) { + return true + } + if (event.type !== 'content_block_delta') return false + + const delta = event.delta + if (!delta) return false + return ( + (typeof delta.text === 'string' && delta.text.length > 0) || + (typeof delta.thinking === 'string' && delta.thinking.length > 0) || + (typeof delta.partial_json === 'string' && + delta.partial_json.length > 0) + ) } async *[Symbol.asyncIterator]() { - yield* this.generator + this.markConsumed() + try { + for await (const event of this.generator) { + if (this.hasMeaningfulOutput(event)) { + this.warmingHint?.resolve() + } + yield event + } + } finally { + this.warmingHint?.resolve() + } } } @@ -1902,12 +1990,13 @@ class OpenAIShimMessages { // de qualquer resposta. É resolvido: no gerador de streaming (quando o // conteúdo chega ou o stream termina), logo abaixo nos caminhos // não-stream, ou aqui no catch se a própria requisição falhar. - scheduleWarmingHint(getSlowHintMs()) + const warmingHint = createWarmingHintController(options?.signal) + warmingHint.schedule(getSlowHintMs()) let response: Response try { response = await self._doRequest(request, params, options) } catch (e) { - resolveWarmingHint() + warmingHint.resolve() throw e } httpResponse = response @@ -1927,13 +2016,15 @@ class OpenAIShimMessages { response, request.resolvedModel, options?.signal, + warmingHint, ), + warmingHint, ) } // Caminhos não-stream: a resposta HTTP já chegou, então o aviso cumpriu // seu papel — limpa antes de coletar/converter o corpo. - resolveWarmingHint() + warmingHint.resolve() if (request.transport === 'codex_responses') { const data = await collectCodexCompletedResponse( diff --git a/src/services/api/openaiShim.warmingHint.test.ts b/src/services/api/openaiShim.warmingHint.test.ts index 1420193055..1d31189ca3 100644 --- a/src/services/api/openaiShim.warmingHint.test.ts +++ b/src/services/api/openaiShim.warmingHint.test.ts @@ -10,6 +10,7 @@ const originalFetch = globalThis.fetch const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, VERBOO_SLOW_HINT_MS: process.env.VERBOO_SLOW_HINT_MS, } @@ -52,6 +53,29 @@ function makeDelayedSseResponse(lines: string[], delayMs: number): Response { ) } +function makeSequencedSseResponse( + entries: Array<{ line: string; delayMs?: number }>, +): Response { + let i = 0 + return new Response( + new ReadableStream({ + async pull(controller) { + const entry = entries[i] + if (!entry) { + controller.close() + return + } + i++ + if (entry.delayMs) { + await new Promise(resolve => setTimeout(resolve, entry.delayMs)) + } + controller.enqueue(encoder.encode(entry.line)) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ) +} + function contentChunks(): string[] { return [ `data: ${JSON.stringify({ @@ -145,3 +169,99 @@ test('NÃO mostra warming-up quando a resposta chega rápido (sem flicker)', asy expect(statusCalls).not.toContain('warming-up') }) + +test('chunk inicial vazio não conta como conteúdo visível', async () => { + process.env.VERBOO_SLOW_HINT_MS = '40' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler(status => statusCalls.push(status)) + + const metadataChunk = `data: ${JSON.stringify({ + id: 'c1', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + })}\n\n` + globalThis.fetch = (async () => + makeSequencedSseResponse([ + { line: metadataChunk }, + { line: contentChunks()[0]!, delayMs: 150 }, + { line: contentChunks()[1]! }, + { line: contentChunks()[2]! }, + ])) as unknown as FetchType + + await drain() + + expect(statusCalls).toEqual(['warming-up', null]) +}) + +test('requisição rápida paralela não cancela o aviso da requisição lenta', async () => { + process.env.VERBOO_SLOW_HINT_MS = '40' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler(status => statusCalls.push(status)) + let fetchCount = 0 + + globalThis.fetch = (async () => { + fetchCount++ + return makeDelayedSseResponse( + contentChunks(), + fetchCount === 1 ? 150 : 0, + ) + }) as unknown as FetchType + + const slowRequest = drain('slow-model') + await new Promise(resolve => setTimeout(resolve, 10)) + const fastRequest = drain('fast-model') + await Promise.all([slowRequest, fastRequest]) + + expect(fetchCount).toBe(2) + expect(statusCalls).toEqual(['warming-up', null]) +}) + +test('stream Responses rápido finaliza o timer sem aviso tardio', async () => { + process.env.OPENAI_API_FORMAT = 'responses' + process.env.VERBOO_SLOW_HINT_MS = '40' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler(status => statusCalls.push(status)) + + globalThis.fetch = (async () => + makeDelayedSseResponse( + [ + `event: response.output_text.delta\ndata: ${JSON.stringify({ delta: 'oi' })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + response: { + id: 'resp_1', + model: 'fake-model', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + })}\n\n`, + ], + 0, + )) as unknown as FetchType + + await drain() + await new Promise(resolve => setTimeout(resolve, 80)) + + expect(statusCalls).not.toContain('warming-up') +}) + +test('falha antes da resposta cancela o timer', async () => { + process.env.VERBOO_SLOW_HINT_MS = '20' + const statusCalls: (string | null)[] = [] + setOpenAIShimRouterStatusHandler(status => statusCalls.push(status)) + globalThis.fetch = (async () => { + throw new Error('network unavailable') + }) as unknown as FetchType + + await expect(drain()).rejects.toThrow() + await new Promise(resolve => setTimeout(resolve, 40)) + + expect(statusCalls).not.toContain('warming-up') +})