Created
March 8, 2026 12:04
-
-
Save longseespace/028d9f0f0a859b810028131ca5a16f52 to your computer and use it in GitHub Desktop.
Setapp AI streaming issue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| import crypto from 'node:crypto'; | |
| const env = process.env; | |
| function printHelp() { | |
| console.log(` | |
| Usage: | |
| node verify-setapp-streaming.mjs [options] | |
| This script compares streaming timing between OpenAI-compatible targets. | |
| It focuses on when SSE text deltas arrive, not just the final output. | |
| Environment variables: | |
| PROMPT | |
| STREAM_MODE responses | chat (default: responses) | |
| MAX_OUTPUT_TOKENS (default: 256) | |
| TIMEOUT_MS (default: 120000) | |
| OPENAI_API_KEY | |
| OPENAI_MODEL | |
| OPENAI_BASE_URL (default: https://api.openai.com/v1) | |
| SETAPP_API_KEY | |
| SETAPP_MODEL | |
| SETAPP_BASE_URL | |
| SETAPP_OPENAI_PATH optional, e.g. v1/responses for legacy Setapp | |
| Options: | |
| --prompt <text> | |
| --mode <responses|chat> | |
| --max-output-tokens <n> | |
| --timeout-ms <n> | |
| --openai-api-key <key> | |
| --openai-model <model> | |
| --openai-base-url <url> | |
| --setapp-api-key <key> | |
| --setapp-model <model> | |
| --setapp-base-url <url> | |
| --setapp-openai-path <path> | |
| --only <openai|setapp> | |
| --help | |
| Examples: | |
| OPENAI_API_KEY=... OPENAI_MODEL=gpt-4.1-mini \\ | |
| SETAPP_API_KEY=... SETAPP_MODEL=openai/gpt-5-mini \\ | |
| SETAPP_BASE_URL=https://api.macpaw.com/ai/api/v1 \\ | |
| PROMPT="Count from 1 to 50, one number per token-ish chunk." \\ | |
| node verify-setapp-streaming.mjs | |
| SETAPP_API_KEY=... SETAPP_MODEL=gpt-5-mini \\ | |
| SETAPP_BASE_URL=https://vendor-api.setapp.com/resource/v1/ai/openai \\ | |
| SETAPP_OPENAI_PATH=v1/responses \\ | |
| PROMPT="Write a short story slowly." \\ | |
| node verify-setapp-streaming.mjs --only setapp | |
| `.trim()); | |
| } | |
| function parseArgs(argv) { | |
| const args = argv.slice(2); | |
| const result = {}; | |
| for (let i = 0; i < args.length; i += 1) { | |
| const arg = args[i]; | |
| if (arg === '--help') { | |
| result.help = true; | |
| continue; | |
| } | |
| if (!arg.startsWith('--')) { | |
| throw new Error(`Unknown positional argument: ${arg}`); | |
| } | |
| const key = arg.slice(2); | |
| const value = args[i + 1]; | |
| if (value == null || value.startsWith('--')) { | |
| throw new Error(`Missing value for --${key}`); | |
| } | |
| result[key] = value; | |
| i += 1; | |
| } | |
| return result; | |
| } | |
| function toInt(value, fallback) { | |
| const parsed = Number.parseInt(String(value ?? ''), 10); | |
| return Number.isFinite(parsed) ? parsed : fallback; | |
| } | |
| function nowMs() { | |
| return Number(process.hrtime.bigint() / 1000000n); | |
| } | |
| function trimTrailingSlash(url) { | |
| return String(url ?? '').trim().replace(/\/+$/, ''); | |
| } | |
| function buildRequestBody({ mode, model, prompt, maxOutputTokens }) { | |
| if (mode === 'chat') { | |
| return { | |
| model, | |
| stream: true, | |
| temperature: 0, | |
| max_tokens: maxOutputTokens, | |
| messages: [ | |
| { | |
| role: 'user', | |
| content: prompt, | |
| }, | |
| ], | |
| }; | |
| } | |
| return { | |
| model, | |
| stream: true, | |
| max_output_tokens: maxOutputTokens, | |
| input: prompt, | |
| }; | |
| } | |
| function buildTargetUrl(baseURL, mode, openaiPath) { | |
| const trimmedBaseURL = trimTrailingSlash(baseURL); | |
| if (!trimmedBaseURL) { | |
| throw new Error('Missing base URL'); | |
| } | |
| if (openaiPath) { | |
| return trimmedBaseURL; | |
| } | |
| const endpoint = mode === 'chat' ? '/chat/completions' : '/responses'; | |
| return `${trimmedBaseURL}${endpoint}`; | |
| } | |
| function parseSSEFrame(frame) { | |
| const lines = frame.split(/\r?\n/); | |
| let eventName = ''; | |
| const dataLines = []; | |
| for (const line of lines) { | |
| if (!line || line.startsWith(':')) continue; | |
| if (line.startsWith('event:')) { | |
| eventName = line.slice(6).trim(); | |
| continue; | |
| } | |
| if (line.startsWith('data:')) { | |
| dataLines.push(line.slice(5).trimStart()); | |
| } | |
| } | |
| return { | |
| eventName: eventName || null, | |
| dataText: dataLines.join('\n'), | |
| }; | |
| } | |
| function extractDeltaText(parsed) { | |
| if (!parsed || typeof parsed !== 'object') return ''; | |
| if (typeof parsed.delta === 'string') { | |
| return parsed.delta; | |
| } | |
| const choiceDelta = parsed.choices?.[0]?.delta; | |
| if (typeof choiceDelta?.content === 'string') { | |
| return choiceDelta.content; | |
| } | |
| if (Array.isArray(choiceDelta?.content)) { | |
| return choiceDelta.content | |
| .map((part) => (typeof part?.text === 'string' ? part.text : '')) | |
| .join(''); | |
| } | |
| if (Array.isArray(parsed.output)) { | |
| return parsed.output | |
| .flatMap((item) => (Array.isArray(item?.content) ? item.content : [])) | |
| .map((part) => (typeof part?.text === 'string' ? part.text : '')) | |
| .join(''); | |
| } | |
| return ''; | |
| } | |
| function summarizeTimings(deltaEvents) { | |
| const gaps = []; | |
| for (let i = 1; i < deltaEvents.length; i += 1) { | |
| gaps.push(deltaEvents[i].atMs - deltaEvents[i - 1].atMs); | |
| } | |
| const sortedGaps = [...gaps].sort((a, b) => a - b); | |
| const percentile = (values, p) => { | |
| if (values.length === 0) return null; | |
| const index = Math.min(values.length - 1, Math.floor(values.length * p)); | |
| return values[index]; | |
| }; | |
| return { | |
| gapCount: gaps.length, | |
| minGapMs: gaps.length > 0 ? Math.min(...gaps) : null, | |
| medianGapMs: percentile(sortedGaps, 0.5), | |
| p95GapMs: percentile(sortedGaps, 0.95), | |
| maxGapMs: gaps.length > 0 ? Math.max(...gaps) : null, | |
| }; | |
| } | |
| async function collectStream(target) { | |
| const mode = target.mode; | |
| const requestBody = buildRequestBody(target); | |
| const requestUrl = buildTargetUrl(target.baseURL, mode, target.openaiPath); | |
| const headers = { | |
| 'content-type': 'application/json', | |
| authorization: `Bearer ${target.apiKey}`, | |
| }; | |
| if (target.openaiPath) { | |
| headers.OpenAIPath = target.openaiPath; | |
| } | |
| const startedAt = nowMs(); | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), target.timeoutMs); | |
| let response; | |
| try { | |
| response = await fetch(requestUrl, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify(requestBody), | |
| signal: controller.signal, | |
| }); | |
| } finally { | |
| clearTimeout(timer); | |
| } | |
| const responseStartedAt = nowMs(); | |
| if (!response.body) { | |
| throw new Error(`No response body for ${target.name}`); | |
| } | |
| const decoder = new TextDecoder(); | |
| const reader = response.body.getReader(); | |
| let buffer = ''; | |
| let rawText = ''; | |
| let firstChunkAt = null; | |
| const events = []; | |
| const deltaEvents = []; | |
| let assembledText = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| const chunkAt = nowMs(); | |
| if (done) { | |
| break; | |
| } | |
| if (firstChunkAt == null) { | |
| firstChunkAt = chunkAt; | |
| } | |
| const decoded = decoder.decode(value, { stream: true }); | |
| rawText += decoded; | |
| buffer += decoded; | |
| let boundaryIndex = buffer.search(/\r?\n\r?\n/); | |
| while (boundaryIndex !== -1) { | |
| const frame = buffer.slice(0, boundaryIndex); | |
| const separatorMatch = buffer.slice(boundaryIndex).match(/^\r?\n\r?\n/); | |
| const separatorLength = separatorMatch ? separatorMatch[0].length : 2; | |
| buffer = buffer.slice(boundaryIndex + separatorLength); | |
| const parsedFrame = parseSSEFrame(frame); | |
| const atMs = chunkAt - startedAt; | |
| if (!parsedFrame.dataText) { | |
| boundaryIndex = buffer.search(/\r?\n\r?\n/); | |
| continue; | |
| } | |
| let parsedJSON = null; | |
| try { | |
| parsedJSON = JSON.parse(parsedFrame.dataText); | |
| } catch {} | |
| const eventType = | |
| parsedJSON?.type || | |
| parsedFrame.eventName || | |
| (parsedFrame.dataText === '[DONE]' ? 'done' : 'message'); | |
| const deltaText = extractDeltaText(parsedJSON); | |
| events.push({ | |
| atMs, | |
| eventType, | |
| deltaLength: deltaText.length, | |
| }); | |
| if (deltaText.length > 0) { | |
| assembledText += deltaText; | |
| deltaEvents.push({ | |
| atMs, | |
| deltaLength: deltaText.length, | |
| }); | |
| } | |
| boundaryIndex = buffer.search(/\r?\n\r?\n/); | |
| } | |
| } | |
| const finishedAt = nowMs(); | |
| const timingSummary = summarizeTimings(deltaEvents); | |
| return { | |
| name: target.name, | |
| requestUrl, | |
| status: response.status, | |
| ok: response.ok, | |
| headers: { | |
| contentType: response.headers.get('content-type'), | |
| transferEncoding: response.headers.get('transfer-encoding'), | |
| cacheControl: response.headers.get('cache-control'), | |
| server: response.headers.get('server'), | |
| }, | |
| startedAt, | |
| responseStartedAt, | |
| firstByteMs: firstChunkAt == null ? null : firstChunkAt - startedAt, | |
| responseHeaderMs: responseStartedAt - startedAt, | |
| totalDurationMs: finishedAt - startedAt, | |
| totalEvents: events.length, | |
| totalDeltaEvents: deltaEvents.length, | |
| totalTextChars: assembledText.length, | |
| assembledTextSha256: crypto.createHash('sha256').update(assembledText).digest('hex'), | |
| assembledTextPreview: assembledText.slice(0, 160), | |
| timingSummary, | |
| firstEventMs: events[0]?.atMs ?? null, | |
| firstDeltaMs: deltaEvents[0]?.atMs ?? null, | |
| sampleDeltaTimeline: deltaEvents.slice(0, 12), | |
| rawPreview: rawText.slice(0, 400), | |
| }; | |
| } | |
| function printSummary(result) { | |
| console.log(`\n=== ${result.name.toUpperCase()} ===`); | |
| console.log(`url: ${result.requestUrl}`); | |
| console.log(`status: ${result.status}`); | |
| console.log(`content-type: ${result.headers.contentType ?? '-'}`); | |
| console.log(`transfer-encoding: ${result.headers.transferEncoding ?? '-'}`); | |
| console.log(`response headers: ${result.responseHeaderMs}ms`); | |
| console.log(`first byte: ${result.firstByteMs ?? '-'}ms`); | |
| console.log(`first SSE event: ${result.firstEventMs ?? '-'}ms`); | |
| console.log(`first text delta: ${result.firstDeltaMs ?? '-'}ms`); | |
| console.log(`total duration: ${result.totalDurationMs}ms`); | |
| console.log(`events: ${result.totalEvents}`); | |
| console.log(`text deltas: ${result.totalDeltaEvents}`); | |
| console.log(`text chars: ${result.totalTextChars}`); | |
| console.log(`sha256: ${result.assembledTextSha256}`); | |
| if (result.timingSummary.gapCount > 0) { | |
| console.log( | |
| `delta gaps ms: min=${result.timingSummary.minGapMs}, median=${result.timingSummary.medianGapMs}, p95=${result.timingSummary.p95GapMs}, max=${result.timingSummary.maxGapMs}`, | |
| ); | |
| } | |
| if (result.sampleDeltaTimeline.length > 0) { | |
| console.log('first delta events:'); | |
| for (const item of result.sampleDeltaTimeline) { | |
| console.log(` t=${item.atMs}ms chars=${item.deltaLength}`); | |
| } | |
| } | |
| if (result.assembledTextPreview) { | |
| console.log(`text preview: ${JSON.stringify(result.assembledTextPreview)}`); | |
| } | |
| } | |
| function printComparison(results) { | |
| if (results.length < 2) return; | |
| console.log('\n=== COMPARISON ==='); | |
| for (const result of results) { | |
| console.log( | |
| `${result.name}: firstDelta=${result.firstDeltaMs ?? '-'}ms, totalDuration=${result.totalDurationMs}ms, deltas=${result.totalDeltaEvents}, maxGap=${result.timingSummary.maxGapMs ?? '-'}ms`, | |
| ); | |
| } | |
| const uniqueHashes = new Set(results.map((result) => result.assembledTextSha256)); | |
| console.log(`same final text: ${uniqueHashes.size === 1 ? 'yes' : 'no'}`); | |
| } | |
| async function main() { | |
| const args = parseArgs(process.argv); | |
| if (args.help) { | |
| printHelp(); | |
| return; | |
| } | |
| const mode = String(args.mode ?? env.STREAM_MODE ?? 'responses').trim().toLowerCase(); | |
| if (mode !== 'responses' && mode !== 'chat') { | |
| throw new Error(`Unsupported mode: ${mode}`); | |
| } | |
| const prompt = String( | |
| args.prompt ?? | |
| env.PROMPT ?? | |
| 'Write the numbers 1 through 40, with a short dash explanation after each number.', | |
| ); | |
| const maxOutputTokens = toInt(args['max-output-tokens'] ?? env.MAX_OUTPUT_TOKENS, 256); | |
| const timeoutMs = toInt(args['timeout-ms'] ?? env.TIMEOUT_MS, 120000); | |
| const only = args.only ? String(args.only).trim().toLowerCase() : null; | |
| const targets = [ | |
| { | |
| name: 'openai', | |
| apiKey: args['openai-api-key'] ?? env.OPENAI_API_KEY, | |
| model: args['openai-model'] ?? env.OPENAI_MODEL, | |
| baseURL: args['openai-base-url'] ?? env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1', | |
| openaiPath: null, | |
| prompt, | |
| mode, | |
| maxOutputTokens, | |
| timeoutMs, | |
| }, | |
| { | |
| name: 'setapp', | |
| apiKey: args['setapp-api-key'] ?? env.SETAPP_API_KEY, | |
| model: args['setapp-model'] ?? env.SETAPP_MODEL, | |
| baseURL: args['setapp-base-url'] ?? env.SETAPP_BASE_URL, | |
| openaiPath: args['setapp-openai-path'] ?? env.SETAPP_OPENAI_PATH ?? null, | |
| prompt, | |
| mode, | |
| maxOutputTokens, | |
| timeoutMs, | |
| }, | |
| ] | |
| .filter((target) => (only ? target.name === only : true)) | |
| .filter((target) => target.apiKey && target.model && target.baseURL); | |
| if (targets.length === 0) { | |
| printHelp(); | |
| throw new Error('No runnable targets configured. Provide API key, model, and base URL.'); | |
| } | |
| const results = []; | |
| for (const target of targets) { | |
| console.log(`\nRunning ${target.name}...`); | |
| const result = await collectStream(target); | |
| printSummary(result); | |
| results.push(result); | |
| } | |
| printComparison(results); | |
| } | |
| main().catch((error) => { | |
| console.error(`\nERROR: ${error?.message || String(error)}`); | |
| process.exit(1); | |
| }); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Results
OpenAI
Setapp old endpoint
Setapp new endpoint: