codefree-proxy 0.1.3 → 0.1.4

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.
@@ -1,6 +1,30 @@
1
1
  import { transformOpenAIStream } from '../compat/zed-tool-call-stream.js';
2
- import { SUPPORTED_MODELS, mapModel } from './model-map.js';
2
+ import { SUPPORTED_MODELS, resolveModel, toUpstreamModel, unsupportedModelMessage } from './model-map.js';
3
3
  import { postUpstreamJson } from './upstream-client.js';
4
+ import { errorMessage, fetchUpstreamWithRetry as fetchUpstreamWithRetryBase, formatUpstreamError, } from './upstream-retry.js';
5
+ /* codefree-image-strip */
6
+ import { sanitizeUpstreamBody } from './image-sanitize.js';
7
+ function applyImageStrip(body, label = 'upstream') {
8
+ // Vision-capable models; preserve their image parts.
9
+ // DeepSeek-V4-Flash-0731 was being skipped too, but upstream rejects image
10
+ // payloads on it with a bare "400 Bad Request" (verified: a single tool
11
+ // message carrying image_url fails, the same message with string content
12
+ // succeeds), so it must be stripped like every other text-only model.
13
+ // TeleXing4.0-29B-A4B explicitly rejects image input, so it is stripped too.
14
+ const visionModel = typeof body?.model === 'string' ? body.model.replace(/-TokenPlan$/, '') : '';
15
+ // GLM-5.3-TokenPlan returns HTTP 400 for direct image payloads; native
16
+ // codefree-o can instead invoke a separate Vision-Inner Agent.
17
+ if (visionModel === 'Qwen3.8-27B' || visionModel === 'DeepSeek-V4.1-Flash-1017')
18
+ return body;
19
+ const { body: next, stripped } = sanitizeUpstreamBody(body);
20
+ if (stripped > 0)
21
+ console.log(`[debug] ${label} stripped ${stripped} image part(s) (upstream rejects multimodal)`);
22
+ return next;
23
+ }
24
+
25
+ const COMPACTION_PROMPT_MARKER = 'You are compacting this conversation into a handoff for another agent';
26
+ const COMPACTION_TRANSCRIPT_LIMIT = 48_000;
27
+ const COMPACTION_HISTORY_THRESHOLD = 20;
4
28
  function logRequest(body) {
5
29
  const model = body.model ?? 'none';
6
30
  const stream = body.stream ?? false;
@@ -8,8 +32,8 @@ function logRequest(body) {
8
32
  const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
9
33
  console.log(`[debug] model=${model} stream=${stream} messages=${msgCount} tools=${hasTools}`);
10
34
  }
11
- function errorMessage(error) {
12
- return error instanceof Error ? error.message : String(error);
35
+ function fetchUpstreamWithRetry(body, credentials, options, label = 'upstream') {
36
+ return fetchUpstreamWithRetryBase(postUpstreamJson, body, credentials, options, label);
13
37
  }
14
38
  function sendError(reply, statusCode, type, error) {
15
39
  reply.code(statusCode);
@@ -18,11 +42,14 @@ function sendError(reply, statusCode, type, error) {
18
42
  function sendOrStreamError(reply, statusCode, type, error, stream) {
19
43
  if (!stream)
20
44
  return sendError(reply, statusCode, type, error);
21
- reply.raw.writeHead(200, {
22
- 'content-type': 'text/event-stream',
23
- 'cache-control': 'no-cache',
24
- connection: 'keep-alive'
25
- });
45
+ // Surface errors as a real status line. A 200 carrying only an error event
46
+ // is indistinguishable from an empty stream to most clients, which then
47
+ // report "empty stream with no finish_reason" instead of the real cause.
48
+ if (!reply.raw.headersSent) {
49
+ reply.raw.writeHead(statusCode, { 'content-type': 'application/json' });
50
+ reply.raw.end(JSON.stringify({ error: { message: errorMessage(error), type } }));
51
+ return reply;
52
+ }
26
53
  reply.raw.write(`data: ${JSON.stringify({ error: { message: errorMessage(error), type } })}\n\n`);
27
54
  reply.raw.write('data: [DONE]\n\n');
28
55
  reply.raw.end();
@@ -34,6 +61,335 @@ function requestBody(body) {
34
61
  }
35
62
  return body;
36
63
  }
64
+ function requestMessages(body) {
65
+ return Array.isArray(body.messages) ? body.messages.filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : [];
66
+ }
67
+ function extractTextContent(content) {
68
+ if (typeof content === 'string')
69
+ return content;
70
+ if (!Array.isArray(content))
71
+ return '';
72
+ return content
73
+ .map((part) => {
74
+ if (!part || typeof part !== 'object' || Array.isArray(part))
75
+ return '';
76
+ const record = part;
77
+ if (typeof record.text === 'string')
78
+ return record.text;
79
+ if (typeof record.input_text === 'string')
80
+ return record.input_text;
81
+ if (record.type === 'image_url')
82
+ return '[image]';
83
+ return '';
84
+ })
85
+ .filter(Boolean)
86
+ .join('\n');
87
+ }
88
+ function isCompactionRequest(body) {
89
+ return requestMessages(body).some((message) => message.role === 'system' && extractTextContent(message.content).includes(COMPACTION_PROMPT_MARKER));
90
+ }
91
+ function isCompactionLikeRequest(body) {
92
+ if (isCompactionRequest(body))
93
+ return true;
94
+ const messages = requestMessages(body);
95
+ const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
96
+ return hasTools && messages.length >= COMPACTION_HISTORY_THRESHOLD;
97
+ }
98
+ function renderToolCall(toolCall) {
99
+ const fn = toolCall.function;
100
+ if (!fn || typeof fn !== 'object' || Array.isArray(fn))
101
+ return '';
102
+ const fnRecord = fn;
103
+ const name = typeof fnRecord.name === 'string' ? fnRecord.name : 'unknown';
104
+ const args = typeof fnRecord.arguments === 'string' ? fnRecord.arguments : '';
105
+ return `ASSISTANT TOOL_CALL ${name}${args ? ` ${args}` : ''}`;
106
+ }
107
+ function renderTranscriptLine(message) {
108
+ const role = typeof message.role === 'string' ? message.role.toUpperCase() : 'UNKNOWN';
109
+ const content = extractTextContent(message.content);
110
+ const lines = [];
111
+ if (role === 'SYSTEM' && content.includes(COMPACTION_PROMPT_MARKER)) {
112
+ return lines;
113
+ }
114
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
115
+ for (const toolCall of toolCalls) {
116
+ const line = renderToolCall(toolCall);
117
+ if (line)
118
+ lines.push(line);
119
+ }
120
+ if (role === 'TOOL') {
121
+ const toolCallId = typeof message.tool_call_id === 'string' ? message.tool_call_id : 'unknown';
122
+ if (content)
123
+ lines.push(`TOOL RESULT ${toolCallId}: ${content}`);
124
+ return lines;
125
+ }
126
+ if (content)
127
+ lines.push(`${role}: ${content}`);
128
+ return lines;
129
+ }
130
+ function trimTranscript(text) {
131
+ if (text.length <= COMPACTION_TRANSCRIPT_LIMIT)
132
+ return text;
133
+ const suffix = text.slice(-COMPACTION_TRANSCRIPT_LIMIT);
134
+ return `[Earlier transcript truncated to fit upstream limits]\n${suffix}`;
135
+ }
136
+ function anthropicText(content) {
137
+ if (typeof content === 'string')
138
+ return content;
139
+ if (!Array.isArray(content))
140
+ return '';
141
+ return content
142
+ .filter((block) => block && typeof block === 'object' && !Array.isArray(block))
143
+ .map((block) => block.type === 'text' ? block.text ?? '' : '')
144
+ .filter(Boolean)
145
+ .join('\n');
146
+ }
147
+ function anthropicSystem(body) {
148
+ return anthropicText(body.system);
149
+ }
150
+ function anthropicImage(block) {
151
+ const source = block.source;
152
+ if (!source || typeof source !== 'object')
153
+ return null;
154
+ if (source.type === 'base64') {
155
+ return {
156
+ type: 'image_url',
157
+ image_url: { url: `data:${source.media_type ?? 'image/png'};base64,${source.data ?? ''}` }
158
+ };
159
+ }
160
+ if (source.type === 'url' && typeof source.url === 'string')
161
+ return { type: 'image_url', image_url: { url: source.url } };
162
+ return null;
163
+ }
164
+ function anthropicToOpenAI(body, mappedModel) {
165
+ const messages = [];
166
+ const system = anthropicSystem(body);
167
+ if (system)
168
+ messages.push({ role: 'system', content: system });
169
+ for (const message of Array.isArray(body.messages) ? body.messages : []) {
170
+ const role = message?.role === 'assistant' ? 'assistant' : 'user';
171
+ const content = message?.content ?? '';
172
+ if (typeof content === 'string') {
173
+ messages.push({ role, content });
174
+ continue;
175
+ }
176
+ if (!Array.isArray(content)) {
177
+ messages.push({ role, content: String(content) });
178
+ continue;
179
+ }
180
+ if (role === 'assistant') {
181
+ const text = [];
182
+ const toolCalls = [];
183
+ for (const block of content) {
184
+ if (!block || typeof block !== 'object')
185
+ continue;
186
+ if (block.type === 'text' && typeof block.text === 'string')
187
+ text.push(block.text);
188
+ else if (block.type === 'thinking' && typeof block.thinking === 'string')
189
+ text.push(`<thinking>\n${block.thinking}\n</thinking>`);
190
+ else if (block.type === 'tool_use') {
191
+ toolCalls.push({
192
+ id: block.id ?? `toolu_${Date.now()}_${toolCalls.length}`,
193
+ type: 'function',
194
+ function: { name: block.name ?? '', arguments: JSON.stringify(block.input ?? {}) }
195
+ });
196
+ }
197
+ }
198
+ const converted = { role: 'assistant', content: text.join('\n') };
199
+ if (toolCalls.length > 0)
200
+ converted.tool_calls = toolCalls;
201
+ messages.push(converted);
202
+ continue;
203
+ }
204
+ const text = [];
205
+ const images = [];
206
+ const toolResults = [];
207
+ for (const block of content) {
208
+ if (!block || typeof block !== 'object')
209
+ continue;
210
+ if (block.type === 'text' && typeof block.text === 'string')
211
+ text.push(block.text);
212
+ else if (block.type === 'image') {
213
+ const image = anthropicImage(block);
214
+ if (image)
215
+ images.push(image);
216
+ }
217
+ else if (block.type === 'tool_result') {
218
+ toolResults.push({ role: 'tool', tool_call_id: block.tool_use_id ?? '', content: anthropicText(block.content) });
219
+ }
220
+ }
221
+ if (images.length > 0)
222
+ messages.push({ role: 'user', content: [...text.map((value) => ({ type: 'text', text: value })), ...images] });
223
+ else if (text.length > 0 || toolResults.length === 0)
224
+ messages.push({ role: 'user', content: text.join('\n') });
225
+ messages.push(...toolResults);
226
+ }
227
+ const tools = Array.isArray(body.tools) ? body.tools.map((tool) => ({
228
+ type: 'function',
229
+ function: { name: tool.name ?? '', description: tool.description ?? '', parameters: tool.input_schema ?? {} }
230
+ })).filter((tool) => tool.function.name) : [];
231
+ const result = { model: mappedModel, messages, stream: body.stream === true };
232
+ if (tools.length > 0)
233
+ result.tools = tools;
234
+ if (body.tool_choice === 'any')
235
+ result.tool_choice = 'required';
236
+ else if (body.tool_choice === 'auto' || body.tool_choice === 'none')
237
+ result.tool_choice = body.tool_choice;
238
+ else if (body.tool_choice?.type === 'tool')
239
+ result.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
240
+ for (const key of ['max_tokens', 'temperature', 'top_p']) {
241
+ if (body[key] !== undefined)
242
+ result[key] = body[key];
243
+ }
244
+ if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0)
245
+ result.stop = body.stop_sequences;
246
+ return result;
247
+ }
248
+ function openAIToAnthropic(data, requestedModel) {
249
+ const choice = Array.isArray(data.choices) ? data.choices[0] ?? {} : {};
250
+ const message = choice.message ?? {};
251
+ const content = [];
252
+ if (typeof message.content === 'string' && message.content.length > 0)
253
+ content.push({ type: 'text', text: message.content });
254
+ for (const toolCall of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
255
+ const fn = toolCall.function ?? {};
256
+ let input = {};
257
+ try {
258
+ input = JSON.parse(fn.arguments ?? '{}');
259
+ }
260
+ catch {
261
+ input = {};
262
+ }
263
+ content.push({ type: 'tool_use', id: toolCall.id ?? `toolu_${Date.now()}_${content.length}`, name: fn.name ?? '', input });
264
+ }
265
+ if (content.length === 0)
266
+ content.push({ type: 'text', text: '' });
267
+ const finishReason = choice.finish_reason;
268
+ const stopReason = finishReason === 'tool_calls' || message.tool_calls?.length > 0
269
+ ? 'tool_use' : finishReason === 'length' ? 'max_tokens' : 'end_turn';
270
+ return {
271
+ id: `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`,
272
+ type: 'message',
273
+ role: 'assistant',
274
+ content,
275
+ model: requestedModel,
276
+ stop_reason: stopReason,
277
+ stop_sequence: null,
278
+ usage: { input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 }
279
+ };
280
+ }
281
+ function anthropicEvent(event, data) {
282
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
283
+ }
284
+ function sendAnthropicStreamError(reply, statusCode, type, error) {
285
+ reply.raw.writeHead(200, {
286
+ 'content-type': 'text/event-stream',
287
+ 'cache-control': 'no-cache',
288
+ connection: 'keep-alive'
289
+ });
290
+ reply.raw.write(anthropicEvent('error', { type: 'error', error: { type, message: errorMessage(error) } }));
291
+ reply.raw.end();
292
+ return reply;
293
+ }
294
+ async function* openAIToAnthropicStream(response, requestedModel) {
295
+ const messageId = `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
296
+ yield anthropicEvent('message_start', {
297
+ type: 'message_start',
298
+ message: {
299
+ id: messageId, type: 'message', role: 'assistant', content: [], model: requestedModel,
300
+ stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 }
301
+ }
302
+ });
303
+ let textOpen = false;
304
+ let textIndex = -1;
305
+ let nextIndex = 0;
306
+ const toolBlocks = new Map();
307
+ let stopReason = 'end_turn';
308
+ let inputTokens = 0;
309
+ let outputTokens = 0;
310
+ for await (const part of responseToChunks(response)) {
311
+ const dataLine = part.split(/\r?\n/).find((line) => line.startsWith('data:'));
312
+ if (!dataLine)
313
+ continue;
314
+ const dataText = dataLine.slice(5).trim();
315
+ if (!dataText || dataText === '[DONE]')
316
+ break;
317
+ let data;
318
+ try {
319
+ data = JSON.parse(dataText);
320
+ }
321
+ catch {
322
+ continue;
323
+ }
324
+ if (data.usage) {
325
+ inputTokens = data.usage.prompt_tokens ?? inputTokens;
326
+ outputTokens = data.usage.completion_tokens ?? outputTokens;
327
+ }
328
+ const choice = Array.isArray(data.choices) ? data.choices[0] : null;
329
+ if (!choice)
330
+ continue;
331
+ const delta = choice.delta ?? {};
332
+ const text = typeof delta.content === 'string' ? delta.content : '';
333
+ if (text) {
334
+ if (!textOpen) {
335
+ textIndex = nextIndex++;
336
+ textOpen = true;
337
+ yield anthropicEvent('content_block_start', { type: 'content_block_start', index: textIndex, content_block: { type: 'text', text: '' } });
338
+ }
339
+ yield anthropicEvent('content_block_delta', { type: 'content_block_delta', index: textIndex, delta: { type: 'text_delta', text } });
340
+ }
341
+ for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
342
+ const key = toolCall.index ?? 0;
343
+ const fn = toolCall.function ?? {};
344
+ let block = toolBlocks.get(key);
345
+ if (!block) {
346
+ block = { index: nextIndex++, id: toolCall.id ?? `toolu_${Date.now()}_${key}`, name: fn.name ?? '' };
347
+ toolBlocks.set(key, block);
348
+ yield anthropicEvent('content_block_start', { type: 'content_block_start', index: block.index, content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} } });
349
+ }
350
+ if (typeof fn.arguments === 'string' && fn.arguments.length > 0) {
351
+ yield anthropicEvent('content_block_delta', { type: 'content_block_delta', index: block.index, delta: { type: 'input_json_delta', partial_json: fn.arguments } });
352
+ }
353
+ }
354
+ if (choice.finish_reason === 'tool_calls')
355
+ stopReason = 'tool_use';
356
+ else if (choice.finish_reason === 'length')
357
+ stopReason = 'max_tokens';
358
+ }
359
+ if (textOpen)
360
+ yield anthropicEvent('content_block_stop', { type: 'content_block_stop', index: textIndex });
361
+ for (const block of toolBlocks.values())
362
+ yield anthropicEvent('content_block_stop', { type: 'content_block_stop', index: block.index });
363
+ yield anthropicEvent('message_delta', { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, usage: { input_tokens: inputTokens, output_tokens: outputTokens } });
364
+ yield anthropicEvent('message_stop', { type: 'message_stop' });
365
+ }
366
+ function buildCompactionFallbackBody(body, mappedModel) {
367
+ const messages = requestMessages(body);
368
+ const systemPrompt = messages
369
+ .map((message) => extractTextContent(message.content))
370
+ .find((content) => content.includes(COMPACTION_PROMPT_MARKER)) ?? COMPACTION_PROMPT_MARKER;
371
+ const transcript = trimTranscript(messages.flatMap(renderTranscriptLine).join('\n'));
372
+ const userPrompt = [
373
+ 'Compact the following conversation into a concise handoff for the next agent.',
374
+ 'Preserve goals, constraints, decisions, unfinished work, and next steps.',
375
+ '',
376
+ transcript
377
+ ].join('\n');
378
+ return {
379
+ ...body,
380
+ model: mappedModel,
381
+ stream: false,
382
+ messages: [
383
+ { role: 'system', content: systemPrompt },
384
+ { role: 'user', content: userPrompt }
385
+ ],
386
+ tools: undefined,
387
+ tool_choice: undefined,
388
+ parallel_tool_calls: undefined,
389
+ response_format: undefined,
390
+ stream_options: undefined
391
+ };
392
+ }
37
393
  async function* responseToChunks(response) {
38
394
  const reader = response.body?.getReader();
39
395
  if (!reader)
@@ -85,6 +441,87 @@ export function registerOpenAIRoutes(app, options) {
85
441
  return sendError(reply, 401, 'auth_error', error);
86
442
  }
87
443
  });
444
+ app.post('/v1/messages', async (request, reply) => {
445
+ let body;
446
+ try {
447
+ body = requestBody(request.body);
448
+ }
449
+ catch (error) {
450
+ return sendError(reply, 400, 'invalid_request_error', error);
451
+ }
452
+ logRequest(body);
453
+ const requestedModel = typeof body.model === 'string' ? body.model : '';
454
+ const canonicalModel = resolveModel(requestedModel);
455
+ if (!canonicalModel)
456
+ return sendError(reply, 400, 'invalid_request_error', new Error(unsupportedModelMessage(requestedModel)));
457
+ const mappedModel = toUpstreamModel(canonicalModel);
458
+ const stream = body.stream === true;
459
+ const upstreamBody = applyImageStrip(anthropicToOpenAI(body, mappedModel), 'anthropic');
460
+ let credentials;
461
+ try {
462
+ credentials = await options.auth.getCredentials();
463
+ }
464
+ catch (error) {
465
+ return stream ? sendAnthropicStreamError(reply, 401, 'authentication_error', error) : sendError(reply, 401, 'auth_error', error);
466
+ }
467
+ const fetchOpts = { fetchImpl: options.upstreamFetch, timeoutMs: options.timeoutMs };
468
+ let upstream;
469
+ let preReadErrorBody = null;
470
+ try {
471
+ ({ response: upstream, errorBody: preReadErrorBody } = await fetchUpstreamWithRetry(upstreamBody, credentials, fetchOpts, 'anthropic-upstream'));
472
+ }
473
+ catch (error) {
474
+ return stream ? sendAnthropicStreamError(reply, 502, 'api_error', error) : sendError(reply, 502, 'api_error', error);
475
+ }
476
+ if (upstream.status === 401 || upstream.status === 403) {
477
+ try {
478
+ credentials = await options.auth.refresh();
479
+ }
480
+ catch (error) {
481
+ return stream ? sendAnthropicStreamError(reply, 401, 'authentication_error', error) : sendError(reply, 401, 'auth_error', error);
482
+ }
483
+ try {
484
+ ({ response: upstream, errorBody: preReadErrorBody } = await fetchUpstreamWithRetry(upstreamBody, credentials, fetchOpts, 'anthropic-upstream-after-refresh'));
485
+ }
486
+ catch (error) {
487
+ return stream ? sendAnthropicStreamError(reply, 502, 'api_error', error) : sendError(reply, 502, 'api_error', error);
488
+ }
489
+ }
490
+ if (!upstream.ok) {
491
+ const message = preReadErrorBody ?? await upstream.text();
492
+ if (!preReadErrorBody)
493
+ console.log(`[debug] anthropic upstream non-ok: status=${upstream.status} body=${message.slice(0, 200)}`);
494
+ const friendly = formatUpstreamError(upstream.status, message);
495
+ if (stream)
496
+ return sendAnthropicStreamError(reply, upstream.status, 'api_error', new Error(friendly));
497
+ reply.code(upstream.status);
498
+ return { type: 'error', error: { type: 'api_error', message: friendly } };
499
+ }
500
+ if (stream) {
501
+ reply.raw.writeHead(200, {
502
+ 'content-type': 'text/event-stream',
503
+ 'cache-control': 'no-cache',
504
+ connection: 'keep-alive'
505
+ });
506
+ try {
507
+ for await (const event of openAIToAnthropicStream(upstream, requestedModel))
508
+ reply.raw.write(event);
509
+ }
510
+ catch (error) {
511
+ reply.raw.write(anthropicEvent('error', { type: 'error', error: { type: 'api_error', message: errorMessage(error) } }));
512
+ }
513
+ reply.raw.end();
514
+ return reply;
515
+ }
516
+ try {
517
+ const data = JSON.parse(await upstream.text());
518
+ reply.header('content-type', 'application/json');
519
+ return openAIToAnthropic(data, requestedModel);
520
+ }
521
+ catch (error) {
522
+ return sendError(reply, 502, 'api_error', error);
523
+ }
524
+ });
88
525
  app.post('/v1/chat/completions', async (request, reply) => {
89
526
  let body;
90
527
  try {
@@ -94,8 +531,11 @@ export function registerOpenAIRoutes(app, options) {
94
531
  return sendError(reply, 400, 'invalid_request_error', error);
95
532
  }
96
533
  logRequest(body);
97
- const mappedModel = mapModel(typeof body.model === 'string' ? body.model : undefined);
98
- const upstreamBody = { ...body, model: mappedModel };
534
+ const canonicalModel = resolveModel(body.model);
535
+ if (!canonicalModel)
536
+ return sendError(reply, 400, 'invalid_request_error', new Error(unsupportedModelMessage(body.model)));
537
+ const mappedModel = toUpstreamModel(canonicalModel);
538
+ let upstreamBody = applyImageStrip({ ...body, model: mappedModel }, 'chat');
99
539
  const stream = body.stream === true;
100
540
  let credentials;
101
541
  try {
@@ -104,9 +544,11 @@ export function registerOpenAIRoutes(app, options) {
104
544
  catch (error) {
105
545
  return sendOrStreamError(reply, 401, 'auth_error', error, stream);
106
546
  }
547
+ const fetchOpts = { fetchImpl: options.upstreamFetch, timeoutMs: options.timeoutMs };
107
548
  let upstream;
549
+ let preReadErrorBody = null;
108
550
  try {
109
- upstream = await postUpstreamJson(upstreamBody, credentials, { fetchImpl: options.upstreamFetch, timeoutMs: options.timeoutMs });
551
+ ({ response: upstream, errorBody: preReadErrorBody } = await fetchUpstreamWithRetry(upstreamBody, credentials, fetchOpts, 'upstream'));
110
552
  }
111
553
  catch (error) {
112
554
  return sendOrStreamError(reply, 502, 'api_error', error, stream);
@@ -119,20 +561,42 @@ export function registerOpenAIRoutes(app, options) {
119
561
  return sendOrStreamError(reply, 401, 'auth_error', error, stream);
120
562
  }
121
563
  try {
122
- upstream = await postUpstreamJson(upstreamBody, credentials, { fetchImpl: options.upstreamFetch, timeoutMs: options.timeoutMs });
564
+ ({ response: upstream, errorBody: preReadErrorBody } = await fetchUpstreamWithRetry(upstreamBody, credentials, fetchOpts, 'upstream-after-refresh'));
123
565
  }
124
566
  catch (error) {
125
567
  return sendOrStreamError(reply, 502, 'api_error', error, stream);
126
568
  }
127
569
  }
128
570
  if (!upstream.ok) {
129
- const message = await upstream.text();
130
- console.log(`[debug] upstream non-ok: status=${upstream.status} body=${message.slice(0, 200)}`);
131
- if (!stream) {
571
+ let message = preReadErrorBody ?? await upstream.text();
572
+ if (!preReadErrorBody)
573
+ console.log(`[debug] upstream non-ok: status=${upstream.status} body=${message.slice(0, 200)}`);
574
+ if (!stream && upstream.status === 400 && isCompactionLikeRequest(body)) {
575
+ upstreamBody = buildCompactionFallbackBody(body, mappedModel);
576
+ const fallbackMessages = requestMessages(upstreamBody);
577
+ const fallbackText = typeof fallbackMessages[1]?.content === 'string' ? fallbackMessages[1].content : '';
578
+ console.log(`[debug] compaction fallback retry: messages=${fallbackMessages.length} chars=${fallbackText.length}`);
579
+ try {
580
+ ({ response: upstream, errorBody: preReadErrorBody } = await fetchUpstreamWithRetry(upstreamBody, credentials, fetchOpts, 'compaction-fallback'));
581
+ message = preReadErrorBody ?? '';
582
+ }
583
+ catch (error) {
584
+ return sendError(reply, 502, 'api_error', error);
585
+ }
586
+ if (!upstream.ok) {
587
+ message = preReadErrorBody ?? await upstream.text();
588
+ console.log(`[debug] compaction fallback non-ok: status=${upstream.status} body=${message.slice(0, 200)}`);
589
+ reply.code(upstream.status);
590
+ return { error: { message: formatUpstreamError(upstream.status, message), type: 'api_error' } };
591
+ }
592
+ }
593
+ else if (!stream) {
132
594
  reply.code(upstream.status);
133
- return { error: { message, type: 'api_error' } };
595
+ return { error: { message: formatUpstreamError(upstream.status, message), type: 'api_error' } };
596
+ }
597
+ if (!upstream.ok) {
598
+ return sendOrStreamError(reply, upstream.status, 'api_error', new Error(formatUpstreamError(upstream.status, message)), true);
134
599
  }
135
- return sendOrStreamError(reply, upstream.status, 'api_error', new Error(message), true);
136
600
  }
137
601
  if (!stream) {
138
602
  reply.header('content-type', 'application/json');
@@ -155,23 +619,57 @@ export function registerOpenAIRoutes(app, options) {
155
619
  return sendError(reply, 502, 'api_error', error);
156
620
  }
157
621
  }
158
- reply.raw.writeHead(200, {
159
- 'content-type': 'text/event-stream',
160
- 'cache-control': 'no-cache',
161
- connection: 'keep-alive'
162
- });
622
+ // Hold the SSE status line until the first real chunk arrives, so an
623
+ // upstream that closes cleanly with zero chunks can still be reported as
624
+ // an error instead of an empty 200. Once content flows, stream it live.
625
+ const iterator = transformOpenAIStream(responseToChunks(upstream));
626
+ const lead = [];
627
+ let committed = false;
628
+ let emittedDone = false;
629
+ let streamError = null;
163
630
  try {
164
- for await (const chunk of transformOpenAIStream(responseToChunks(upstream))) {
631
+ for await (const chunk of iterator) {
632
+ if (!committed) {
633
+ if (/"choices"/.test(chunk)) {
634
+ committed = true;
635
+ reply.raw.writeHead(200, {
636
+ 'content-type': 'text/event-stream',
637
+ 'cache-control': 'no-cache',
638
+ connection: 'keep-alive'
639
+ });
640
+ for (const buffered of lead)
641
+ reply.raw.write(buffered);
642
+ lead.length = 0;
643
+ reply.raw.write(chunk);
644
+ if (/\[DONE\]/.test(chunk))
645
+ emittedDone = true;
646
+ }
647
+ else if (!/\[DONE\]/.test(chunk)) {
648
+ lead.push(chunk);
649
+ }
650
+ continue;
651
+ }
165
652
  reply.raw.write(chunk);
653
+ if (/\[DONE\]/.test(chunk))
654
+ emittedDone = true;
166
655
  }
167
656
  }
168
657
  catch (error) {
658
+ streamError = error;
169
659
  console.log(`[debug] stream transform error: ${errorMessage(error)}`);
170
- reply.raw.write(`data: ${JSON.stringify({ error: { message: errorMessage(error), type: 'api_error' } })}\n\n`);
171
660
  }
172
- reply.raw.write('data: [DONE]\n\n');
661
+ if (!committed) {
662
+ const detail = streamError ? errorMessage(streamError) : 'upstream returned an empty stream';
663
+ console.log(`[debug] empty stream: ${detail}`);
664
+ return sendOrStreamError(reply, 502, 'api_error', new Error(`empty_stream: ${detail}`), true);
665
+ }
666
+ if (streamError) {
667
+ reply.raw.write(`data: ${JSON.stringify({ error: { message: errorMessage(streamError), type: 'api_error' } })}\n\n`);
668
+ }
669
+ if (!emittedDone)
670
+ reply.raw.write('data: [DONE]\n\n');
173
671
  reply.raw.end();
174
672
  return reply;
175
673
  });
176
674
  }
177
- //# sourceMappingURL=openai-routes.js.map
675
+ //# sourceMappingURL=openai-routes.js.map