|
1 | | -import { describe, it } from 'node:test'; |
| 1 | +import { afterEach, describe, it } from 'node:test'; |
2 | 2 | import assert from 'node:assert/strict'; |
3 | 3 | import http2 from 'http2'; |
4 | 4 | import { isCascadeTransportError } from '../src/client.js'; |
5 | | -import { chatStreamError, isUpstreamDeadlineExceeded, isUpstreamTransientError, redactRequestLogText } from '../src/handlers/chat.js'; |
| 5 | +import { addAccountByKey, getApiKey, removeAccount } from '../src/auth.js'; |
| 6 | +import { |
| 7 | + chatStreamError, |
| 8 | + finishPartialStreamAfterError, |
| 9 | + handleChatCompletions, |
| 10 | + isUpstreamDeadlineExceeded, |
| 11 | + isUpstreamTransientError, |
| 12 | + redactRequestLogText, |
| 13 | +} from '../src/handlers/chat.js'; |
6 | 14 | import { handleMessages } from '../src/handlers/messages.js'; |
7 | 15 |
|
| 16 | +const createdAccountIds = []; |
| 17 | + |
| 18 | +afterEach(() => { |
| 19 | + while (createdAccountIds.length) { |
| 20 | + removeAccount(createdAccountIds.pop()); |
| 21 | + } |
| 22 | +}); |
| 23 | + |
8 | 24 | function parseEvents(raw) { |
9 | 25 | return raw.trim().split('\n\n').filter(Boolean).map(frame => { |
10 | 26 | const lines = frame.split('\n'); |
@@ -102,6 +118,124 @@ describe('stream error protocol', () => { |
102 | 118 | assert.equal(events[0].data.error.type, 'upstream_transient_error'); |
103 | 119 | }); |
104 | 120 |
|
| 121 | + it('closes partial OpenAI streams without appending an error JSON frame', () => { |
| 122 | + const res = fakeRes(); |
| 123 | + const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`); |
| 124 | + |
| 125 | + send({ |
| 126 | + id: 'chatcmpl_partial', |
| 127 | + object: 'chat.completion.chunk', |
| 128 | + created: 1, |
| 129 | + model: 'claude-sonnet-4.6', |
| 130 | + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], |
| 131 | + }); |
| 132 | + send({ |
| 133 | + id: 'chatcmpl_partial', |
| 134 | + object: 'chat.completion.chunk', |
| 135 | + created: 1, |
| 136 | + model: 'claude-sonnet-4.6', |
| 137 | + choices: [{ index: 0, delta: { content: 'partial answer' }, finish_reason: null }], |
| 138 | + }); |
| 139 | + |
| 140 | + finishPartialStreamAfterError({ |
| 141 | + id: 'chatcmpl_partial', |
| 142 | + created: 1, |
| 143 | + model: 'claude-sonnet-4.6', |
| 144 | + send, |
| 145 | + res, |
| 146 | + }); |
| 147 | + res.end(); |
| 148 | + |
| 149 | + assert.equal(res.body.includes('"error"'), false); |
| 150 | + const frames = res.body |
| 151 | + .split('\n\n') |
| 152 | + .filter(Boolean) |
| 153 | + .map(frame => frame.split('\n').find(line => line.startsWith('data: '))?.slice(6)) |
| 154 | + .filter(Boolean); |
| 155 | + assert.equal(frames.at(-1), '[DONE]'); |
| 156 | + const finish = JSON.parse(frames.at(-2)); |
| 157 | + assert.equal(finish.choices[0].finish_reason, 'stop'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('does not append stream error JSON after content already reached the client', async () => { |
| 161 | + const account = addAccountByKey(`partial-deadline-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'partial-deadline'); |
| 162 | + createdAccountIds.push(account.id); |
| 163 | + |
| 164 | + class PartialDeadlineClient { |
| 165 | + async cascadeChat(_messages, _modelEnum, _modelUid, opts = {}) { |
| 166 | + opts.onChunk({ text: 'partial answer' }); |
| 167 | + throw new Error('Encountered retryable error from model provider: context deadline exceeded (Client.Timeout or context cancellation while reading body)'); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + const result = await handleChatCompletions({ |
| 172 | + model: 'gemini-2.5-flash', |
| 173 | + stream: true, |
| 174 | + messages: [{ role: 'user', content: 'write a long answer' }], |
| 175 | + }, { |
| 176 | + async waitForAccount(tried, _signal, _maxWaitMs, modelKey) { |
| 177 | + return tried.length === 0 ? getApiKey(tried, modelKey) : null; |
| 178 | + }, |
| 179 | + async ensureLs() {}, |
| 180 | + getLsFor() { |
| 181 | + return { port: 17777, csrfToken: 'csrf', generation: 1 }; |
| 182 | + }, |
| 183 | + WindsurfClient: PartialDeadlineClient, |
| 184 | + }); |
| 185 | + |
| 186 | + assert.equal(result.status, 200); |
| 187 | + assert.equal(result.stream, true); |
| 188 | + |
| 189 | + const res = fakeRes(); |
| 190 | + await result.handler(res); |
| 191 | + |
| 192 | + assert.match(res.body, /partial answer/); |
| 193 | + assert.equal(res.body.includes('"error"'), false); |
| 194 | + const frames = res.body |
| 195 | + .split('\n\n') |
| 196 | + .filter(Boolean) |
| 197 | + .filter(frame => !frame.startsWith(':')) |
| 198 | + .map(frame => frame.split('\n').find(line => line.startsWith('data: '))?.slice(6)) |
| 199 | + .filter(Boolean); |
| 200 | + assert.equal(frames.at(-1), '[DONE]'); |
| 201 | + const finish = JSON.parse(frames.at(-2)); |
| 202 | + assert.equal(finish.choices[0].finish_reason, 'stop'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('still sends a structured stream error when only an empty role chunk was emitted', async () => { |
| 206 | + const account = addAccountByKey(`empty-deadline-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'empty-deadline'); |
| 207 | + createdAccountIds.push(account.id); |
| 208 | + |
| 209 | + class EmptyThenDeadlineClient { |
| 210 | + async cascadeChat(_messages, _modelEnum, _modelUid, opts = {}) { |
| 211 | + opts.onChunk({ text: '' }); |
| 212 | + throw new Error('Encountered retryable error from model provider: context deadline exceeded (Client.Timeout or context cancellation while reading body)'); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + const result = await handleChatCompletions({ |
| 217 | + model: 'gemini-2.5-flash', |
| 218 | + stream: true, |
| 219 | + messages: [{ role: 'user', content: 'hi' }], |
| 220 | + }, { |
| 221 | + async waitForAccount(tried, _signal, _maxWaitMs, modelKey) { |
| 222 | + return tried.length === 0 ? getApiKey(tried, modelKey) : null; |
| 223 | + }, |
| 224 | + async ensureLs() {}, |
| 225 | + getLsFor() { |
| 226 | + return { port: 17777, csrfToken: 'csrf', generation: 1 }; |
| 227 | + }, |
| 228 | + WindsurfClient: EmptyThenDeadlineClient, |
| 229 | + }); |
| 230 | + |
| 231 | + const res = fakeRes(); |
| 232 | + await result.handler(res); |
| 233 | + |
| 234 | + assert.match(res.body, /"error"/); |
| 235 | + assert.match(res.body, /"type":"upstream_deadline_exceeded"/); |
| 236 | + assert.match(res.body, /data: \[DONE\]/); |
| 237 | + }); |
| 238 | + |
105 | 239 | it('routes oversized Connect frame parser errors to onError without throwing from data handlers', async () => { |
106 | 240 | const previousProtocol = process.env.GRPC_PROTOCOL; |
107 | 241 | process.env.GRPC_PROTOCOL = 'connect'; |
|
0 commit comments