@khanglvm/llm-router 1.3.1 → 2.0.0-beta.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +337 -41
  3. package/package.json +19 -3
  4. package/src/cli/router-module.js +7331 -3805
  5. package/src/cli/wrangler-toml.js +1 -1
  6. package/src/cli-entry.js +162 -24
  7. package/src/node/amp-client-config.js +426 -0
  8. package/src/node/coding-tool-config.js +763 -0
  9. package/src/node/config-store.js +49 -18
  10. package/src/node/instance-state.js +213 -12
  11. package/src/node/listen-port.js +5 -37
  12. package/src/node/local-server-settings.js +122 -0
  13. package/src/node/local-server.js +3 -2
  14. package/src/node/provider-probe.js +13 -0
  15. package/src/node/start-command.js +282 -40
  16. package/src/node/startup-manager.js +64 -29
  17. package/src/node/web-command.js +106 -0
  18. package/src/node/web-console-assets.js +26 -0
  19. package/src/node/web-console-client.js +56 -0
  20. package/src/node/web-console-dev-assets.js +258 -0
  21. package/src/node/web-console-server.js +3146 -0
  22. package/src/node/web-console-styles.generated.js +1 -0
  23. package/src/node/web-console-ui/config-editor-utils.js +616 -0
  24. package/src/node/web-console-ui/lib/utils.js +6 -0
  25. package/src/node/web-console-ui/rate-limit-utils.js +144 -0
  26. package/src/node/web-console-ui/select-search-utils.js +36 -0
  27. package/src/runtime/codex-request-transformer.js +46 -5
  28. package/src/runtime/codex-response-transformer.js +268 -35
  29. package/src/runtime/config.js +1394 -35
  30. package/src/runtime/handler/amp-gemini.js +913 -0
  31. package/src/runtime/handler/amp-response.js +308 -0
  32. package/src/runtime/handler/amp.js +290 -0
  33. package/src/runtime/handler/auth.js +17 -2
  34. package/src/runtime/handler/provider-call.js +168 -50
  35. package/src/runtime/handler/provider-translation.js +937 -26
  36. package/src/runtime/handler/request.js +149 -6
  37. package/src/runtime/handler/route-debug.js +22 -1
  38. package/src/runtime/handler.js +449 -9
  39. package/src/runtime/subscription-auth.js +1 -6
  40. package/src/shared/local-router-defaults.js +62 -0
  41. package/src/translator/index.js +3 -1
  42. package/src/translator/request/openai-to-claude.js +217 -6
  43. package/src/translator/response/openai-to-claude.js +206 -58
@@ -0,0 +1,144 @@
1
+ export const RATE_LIMIT_ALL_MODELS_SELECTOR = "all";
2
+ export const RATE_LIMIT_WINDOW_OPTIONS = ["second", "minute", "hour", "day", "week", "month"];
3
+
4
+ function normalizePositiveInteger(value, fallback = 0) {
5
+ const parsed = Number.parseInt(String(value || ""), 10);
6
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
7
+ }
8
+
9
+ export function normalizeRateLimitWindowUnit(value, fallback = "") {
10
+ const normalized = String(value || "").trim().toLowerCase();
11
+ if (RATE_LIMIT_WINDOW_OPTIONS.includes(normalized)) return normalized;
12
+ return fallback;
13
+ }
14
+
15
+ export function pluralizeRateLimitWindowUnit(unit, windowValue = 1) {
16
+ const normalized = String(unit || "").trim().toLowerCase().replace(/s$/, "");
17
+ if (!normalized) return "windows";
18
+ return Number(windowValue) === 1 ? normalized : `${normalized}s`;
19
+ }
20
+
21
+ export function normalizeRateLimitModelSelectors(values = []) {
22
+ const normalized = [];
23
+ const seen = new Set();
24
+
25
+ for (const value of (Array.isArray(values) ? values : [values])) {
26
+ const trimmed = String(value || "").trim();
27
+ if (!trimmed) continue;
28
+ if (trimmed.toLowerCase() === RATE_LIMIT_ALL_MODELS_SELECTOR) {
29
+ return [RATE_LIMIT_ALL_MODELS_SELECTOR];
30
+ }
31
+ if (seen.has(trimmed)) continue;
32
+ seen.add(trimmed);
33
+ normalized.push(trimmed);
34
+ }
35
+
36
+ return normalized;
37
+ }
38
+
39
+ export function buildAutoRateLimitBucketId({ requests, windowValue, windowUnit }) {
40
+ const normalizedRequests = normalizePositiveInteger(requests, 0);
41
+ const normalizedWindowValue = normalizePositiveInteger(windowValue, 0);
42
+ const normalizedWindowUnit = normalizeRateLimitWindowUnit(windowUnit, "");
43
+ if (!normalizedRequests || !normalizedWindowValue || !normalizedWindowUnit) return "";
44
+ return `${normalizedRequests}-req-per-${normalizedWindowValue}-${pluralizeRateLimitWindowUnit(normalizedWindowUnit, normalizedWindowValue)}`;
45
+ }
46
+
47
+ export function formatRateLimitBucketCap(bucket = {}) {
48
+ const requests = normalizePositiveInteger(bucket?.requests ?? bucket?.limit, 0);
49
+ const windowValue = normalizePositiveInteger(bucket?.window?.size ?? bucket?.window?.value ?? bucket?.windowValue, 0);
50
+ const windowUnit = normalizeRateLimitWindowUnit(bucket?.window?.unit ?? bucket?.windowUnit, "");
51
+ if (!requests || !windowValue || !windowUnit) return "Unconfigured";
52
+ return `${requests}/${windowValue} ${pluralizeRateLimitWindowUnit(windowUnit, windowValue)}`;
53
+ }
54
+
55
+ export function validateRateLimitDraftRows(rows = [], {
56
+ knownModelIds = [],
57
+ requireAtLeastOne = true
58
+ } = {}) {
59
+ const normalizedRows = Array.isArray(rows) ? rows : [];
60
+ if (requireAtLeastOne && normalizedRows.length === 0) {
61
+ return "Add at least one rate-limit entity.";
62
+ }
63
+
64
+ const knownModels = new Set((knownModelIds || []).map((modelId) => String(modelId || "").trim()).filter(Boolean));
65
+ const seenBucketIds = new Set();
66
+
67
+ for (const row of normalizedRows) {
68
+ const models = normalizeRateLimitModelSelectors(row?.models || []);
69
+ if (knownModels.size > 0 && models.some((modelId) => modelId !== RATE_LIMIT_ALL_MODELS_SELECTOR && !knownModels.has(modelId))) {
70
+ return "Rate-limit model selectors must match the provider model ids.";
71
+ }
72
+
73
+ const requests = normalizePositiveInteger(row?.requests, 0);
74
+ if (!requests) {
75
+ return "Rate-limit requests must be a positive integer.";
76
+ }
77
+
78
+ const windowValue = normalizePositiveInteger(row?.windowValue, 0);
79
+ if (!windowValue) {
80
+ return "Rate-limit window size must be a positive integer.";
81
+ }
82
+
83
+ const windowUnit = normalizeRateLimitWindowUnit(row?.windowUnit, "");
84
+ if (!windowUnit) {
85
+ return "Rate-limit window unit is invalid.";
86
+ }
87
+
88
+ const bucketId = buildAutoRateLimitBucketId({ requests, windowValue, windowUnit });
89
+ if (seenBucketIds.has(bucketId)) {
90
+ return "Duplicate rate-limit entities are not allowed.";
91
+ }
92
+ seenBucketIds.add(bucketId);
93
+ }
94
+
95
+ return "";
96
+ }
97
+
98
+ export function buildRateLimitBucketsFromDraftRows(rows = [], {
99
+ existingBucketsBySourceId = new Map(),
100
+ fallbackRequests = 60,
101
+ fallbackWindowValue = 1,
102
+ fallbackWindowUnit = "minute"
103
+ } = {}) {
104
+ return (Array.isArray(rows) ? rows : []).map((row, index) => {
105
+ const sourceId = String(row?.sourceId || "").trim();
106
+ const existingBucket = sourceId && existingBucketsBySourceId instanceof Map && existingBucketsBySourceId.has(sourceId)
107
+ ? structuredClone(existingBucketsBySourceId.get(sourceId))
108
+ : {};
109
+ const requests = normalizePositiveInteger(
110
+ row?.requests,
111
+ normalizePositiveInteger(existingBucket?.requests ?? existingBucket?.limit, fallbackRequests)
112
+ );
113
+ const windowValue = normalizePositiveInteger(
114
+ row?.windowValue,
115
+ normalizePositiveInteger(existingBucket?.window?.size ?? existingBucket?.window?.value, fallbackWindowValue)
116
+ );
117
+ const windowUnit = normalizeRateLimitWindowUnit(
118
+ row?.windowUnit,
119
+ normalizeRateLimitWindowUnit(existingBucket?.window?.unit, fallbackWindowUnit) || fallbackWindowUnit
120
+ );
121
+ const models = normalizeRateLimitModelSelectors(row?.models || []);
122
+ const effectiveModels = models.length > 0 ? models : [RATE_LIMIT_ALL_MODELS_SELECTOR];
123
+
124
+ const bucket = {
125
+ ...existingBucket,
126
+ id: buildAutoRateLimitBucketId({ requests, windowValue, windowUnit }) || `rate-limit-${index + 1}`,
127
+ models: effectiveModels,
128
+ requests,
129
+ window: {
130
+ ...(existingBucket?.window && typeof existingBucket.window === "object" && !Array.isArray(existingBucket.window) ? existingBucket.window : {}),
131
+ size: windowValue,
132
+ unit: windowUnit
133
+ }
134
+ };
135
+
136
+ delete bucket.name;
137
+ delete bucket.limit;
138
+ if (bucket.window && typeof bucket.window === "object") {
139
+ delete bucket.window.value;
140
+ }
141
+
142
+ return bucket;
143
+ });
144
+ }
@@ -0,0 +1,36 @@
1
+ function normalizeSelectSearchText(value) {
2
+ return String(value || "").trim().toLowerCase();
3
+ }
4
+
5
+ export function hasSelectSearchQuery(value) {
6
+ return normalizeSelectSearchText(value).length > 0;
7
+ }
8
+
9
+ export function getSelectSearchKey(event) {
10
+ const key = String(event?.key || "");
11
+ if (!key || key.length !== 1) return "";
12
+ if (event?.ctrlKey || event?.metaKey || event?.altKey) return "";
13
+ return key.trim() ? key : "";
14
+ }
15
+
16
+ export function optionMatchesSelectQuery(option = {}, query = "") {
17
+ const normalizedQuery = normalizeSelectSearchText(query);
18
+ if (!normalizedQuery) return true;
19
+
20
+ const haystack = [
21
+ option?.label,
22
+ option?.value,
23
+ option?.hint,
24
+ option?.textValue,
25
+ option?.searchText
26
+ ]
27
+ .map((entry) => String(entry || "").toLowerCase())
28
+ .join(" ");
29
+
30
+ return haystack.includes(normalizedQuery);
31
+ }
32
+
33
+ export function filterSelectOptions(options = [], query = "") {
34
+ const normalizedOptions = Array.isArray(options) ? options : [];
35
+ return normalizedOptions.filter((option) => optionMatchesSelectQuery(option, query));
36
+ }
@@ -27,12 +27,14 @@ export function transformRequestForCodex(body) {
27
27
  ? { ...body }
28
28
  : {};
29
29
 
30
- const instructions = typeof transformed.instructions === 'string'
31
- ? transformed.instructions.trim()
32
- : '';
33
30
  const reasoning = normalizeReasoningConfig(transformed.reasoning, transformed.reasoning_effort);
34
31
  const include = normalizeIncludeList(transformed.include, reasoning);
35
- const input = resolveResponseInput(transformed);
32
+ const resolvedInput = resolveResponseInput(transformed);
33
+ const extractedGuidance = extractLeadingInstructionMessages(resolvedInput);
34
+ const instructions = joinInstructionText(
35
+ typeof transformed.instructions === 'string' ? transformed.instructions.trim() : '',
36
+ extractedGuidance.instructions
37
+ );
36
38
  const tools = Array.isArray(transformed.tools)
37
39
  ? transformed.tools.map(normalizeToolDefinitionForResponses).filter(Boolean)
38
40
  : [];
@@ -40,7 +42,7 @@ export function transformRequestForCodex(body) {
40
42
  const output = {
41
43
  model: transformed.model,
42
44
  instructions: instructions || DEFAULT_CODEX_INSTRUCTIONS,
43
- input,
45
+ input: extractedGuidance.input,
44
46
  tools,
45
47
  tool_choice: normalizeToolChoiceForResponses(transformed.tool_choice),
46
48
  parallel_tool_calls: Boolean(transformed.parallel_tool_calls),
@@ -68,6 +70,13 @@ function hasUsableInput(input) {
68
70
  return Array.isArray(input) && input.length > 0;
69
71
  }
70
72
 
73
+ function joinInstructionText(...parts) {
74
+ return parts
75
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
76
+ .filter(Boolean)
77
+ .join('\n\n');
78
+ }
79
+
71
80
  function resolveResponseInput(transformed) {
72
81
  if (hasUsableInput(transformed.input)) return transformed.input;
73
82
  if (typeof transformed.input === 'string' && transformed.input.trim()) {
@@ -83,6 +92,38 @@ function resolveResponseInput(transformed) {
83
92
  return [];
84
93
  }
85
94
 
95
+ function isInstructionMessageItem(item) {
96
+ if (!item || typeof item !== 'object') return false;
97
+ if (item.type !== 'message') return false;
98
+ const role = normalizeMessageRole(item.role);
99
+ return role === 'system' || role === 'developer';
100
+ }
101
+
102
+ function extractLeadingInstructionMessages(input) {
103
+ if (!Array.isArray(input) || input.length === 0) {
104
+ return {
105
+ instructions: '',
106
+ input: []
107
+ };
108
+ }
109
+
110
+ const instructions = [];
111
+ let index = 0;
112
+
113
+ while (index < input.length) {
114
+ const item = input[index];
115
+ if (!isInstructionMessageItem(item)) break;
116
+ const text = normalizeMessageContentToText(item.content);
117
+ if (text) instructions.push(text);
118
+ index += 1;
119
+ }
120
+
121
+ return {
122
+ instructions: joinInstructionText(...instructions),
123
+ input: index > 0 ? input.slice(index) : input
124
+ };
125
+ }
126
+
86
127
  function normalizeIncludeList(rawInclude, reasoning) {
87
128
  const include = Array.isArray(rawInclude)
88
129
  ? rawInclude.map((value) => String(value || '').trim()).filter(Boolean)
@@ -133,6 +133,49 @@ function ensureAssistantRoleChunk(state, chunks) {
133
133
  chunks.push(makeOpenAIChunk(state, { role: 'assistant' }, null));
134
134
  }
135
135
 
136
+ function commonPrefixLength(left, right) {
137
+ const leftText = typeof left === 'string' ? left : '';
138
+ const rightText = typeof right === 'string' ? right : '';
139
+ const limit = Math.min(leftText.length, rightText.length);
140
+ let index = 0;
141
+
142
+ while (index < limit && leftText[index] === rightText[index]) {
143
+ index += 1;
144
+ }
145
+
146
+ return index;
147
+ }
148
+
149
+ function getMissingSuffix(emittedText, finalText) {
150
+ const emitted = typeof emittedText === 'string' ? emittedText : '';
151
+ const finalValue = typeof finalText === 'string' ? finalText : '';
152
+
153
+ if (!finalValue) return '';
154
+ if (!emitted) return finalValue;
155
+ if (finalValue.startsWith(emitted)) {
156
+ return finalValue.slice(emitted.length);
157
+ }
158
+ if (emitted.startsWith(finalValue)) {
159
+ return '';
160
+ }
161
+
162
+ const prefixLength = commonPrefixLength(emitted, finalValue);
163
+ if (prefixLength <= 0) return '';
164
+ return finalValue.slice(prefixLength);
165
+ }
166
+
167
+ function parseStreamBlock(block) {
168
+ const normalized = String(block || '').trim();
169
+ if (!normalized) return null;
170
+ if (!normalized.includes('data:') && !normalized.includes('event:')) {
171
+ return {
172
+ eventType: '',
173
+ data: normalized
174
+ };
175
+ }
176
+ return parseSseBlock(normalized);
177
+ }
178
+
136
179
  function parseSseBlock(block) {
137
180
  let eventType = '';
138
181
  const dataLines = [];
@@ -185,7 +228,7 @@ export function extractCodexFinalResponseFromText(rawText) {
185
228
 
186
229
  for (const block of blocks) {
187
230
  if (!block || !block.trim()) continue;
188
- const parsedBlock = parseSseBlock(block);
231
+ const parsedBlock = parseStreamBlock(block);
189
232
  if (!parsedBlock.data || parsedBlock.data === '[DONE]') continue;
190
233
 
191
234
  let payload;
@@ -241,12 +284,114 @@ function updateStateFromResponse(state, response, fallbackModel) {
241
284
  }
242
285
  }
243
286
 
287
+ function extractAssistantOutputText(item) {
288
+ if (!item || item.type !== 'message' || item.role !== 'assistant' || !Array.isArray(item.content)) {
289
+ return '';
290
+ }
291
+
292
+ const textParts = [];
293
+ for (const contentPart of item.content) {
294
+ if (!contentPart || typeof contentPart !== 'object') continue;
295
+ if (contentPart.type === 'output_text' && typeof contentPart.text === 'string') {
296
+ textParts.push(contentPart.text);
297
+ continue;
298
+ }
299
+ if (contentPart.type === 'refusal' && typeof contentPart.refusal === 'string') {
300
+ textParts.push(contentPart.refusal);
301
+ }
302
+ }
303
+
304
+ return textParts.join('');
305
+ }
306
+
307
+ function emitFallbackTextChunk(state, item, chunks) {
308
+ const text = extractAssistantOutputText(item);
309
+ if (!text) return;
310
+
311
+ const itemId = typeof item?.id === 'string' ? item.id.trim() : '';
312
+ const missingText = itemId
313
+ ? getMissingSuffix(state.textOutputByItemId.get(itemId) || '', text)
314
+ : (state.hasTextOutput ? '' : text);
315
+ if (!missingText) return;
316
+
317
+ ensureAssistantRoleChunk(state, chunks);
318
+ chunks.push(makeOpenAIChunk(state, { content: missingText }, null));
319
+ if (itemId) {
320
+ state.textOutputItemIds.add(itemId);
321
+ state.textOutputByItemId.set(itemId, `${state.textOutputByItemId.get(itemId) || ''}${missingText}`);
322
+ }
323
+ state.hasTextOutput = true;
324
+ }
325
+
326
+ function emitFallbackToolCallChunks(state, item, outputIndex, chunks) {
327
+ if (!item || item.type !== 'function_call') return;
328
+
329
+ ensureAssistantRoleChunk(state, chunks);
330
+ state.hasToolCalls = true;
331
+
332
+ const toolIndex = resolveToolIndex(state, {
333
+ output_index: outputIndex,
334
+ item_id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : undefined
335
+ });
336
+
337
+ if (!state.toolCallStartSentByIndex.has(toolIndex)) {
338
+ chunks.push(makeOpenAIChunk(state, {
339
+ tool_calls: [
340
+ {
341
+ index: toolIndex,
342
+ id: String(item.call_id || item.id || `call_${toolIndex}`),
343
+ type: 'function',
344
+ function: {
345
+ name: String(item.name || 'tool'),
346
+ arguments: ''
347
+ }
348
+ }
349
+ ]
350
+ }, null));
351
+ state.toolCallStartSentByIndex.add(toolIndex);
352
+ }
353
+
354
+ const argumentsText = typeof item.arguments === 'string' ? item.arguments : '';
355
+ const missingArguments = getMissingSuffix(state.toolCallArgumentsByIndex.get(toolIndex) || '', argumentsText);
356
+ if (missingArguments) {
357
+ chunks.push(makeOpenAIChunk(state, {
358
+ tool_calls: [
359
+ {
360
+ index: toolIndex,
361
+ function: {
362
+ arguments: missingArguments
363
+ }
364
+ }
365
+ ]
366
+ }, null));
367
+ state.toolCallArgumentsSeenByIndex.add(toolIndex);
368
+ state.toolCallArgumentsByIndex.set(toolIndex, `${state.toolCallArgumentsByIndex.get(toolIndex) || ''}${missingArguments}`);
369
+ }
370
+ }
371
+
372
+ function emitResponseOutputFallbacks(state, response, chunks) {
373
+ const outputItems = Array.isArray(response?.output) ? response.output : [];
374
+ for (let index = 0; index < outputItems.length; index += 1) {
375
+ const item = outputItems[index];
376
+ if (!item || typeof item !== 'object') continue;
377
+
378
+ if (item.type === 'message' && item.role === 'assistant') {
379
+ emitFallbackTextChunk(state, item, chunks);
380
+ continue;
381
+ }
382
+
383
+ if (item.type === 'function_call') {
384
+ emitFallbackToolCallChunks(state, item, index, chunks);
385
+ }
386
+ }
387
+ }
388
+
244
389
  function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
245
390
  if (!event || typeof event !== 'object') return [];
246
391
  const type = String(event.type || '').trim();
247
392
  const chunks = [];
248
393
 
249
- if (type === 'response.created' || type === 'response.in_progress' || type === 'response.output_item.done') {
394
+ if (type === 'response.created' || type === 'response.in_progress') {
250
395
  updateStateFromResponse(state, event.response, fallbackModel);
251
396
  return chunks;
252
397
  }
@@ -259,6 +404,7 @@ function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
259
404
  ensureAssistantRoleChunk(state, chunks);
260
405
  const toolIndex = resolveToolIndex(state, event);
261
406
  state.hasToolCalls = true;
407
+ state.toolCallStartSentByIndex.add(toolIndex);
262
408
  chunks.push(makeOpenAIChunk(state, {
263
409
  tool_calls: [
264
410
  {
@@ -280,34 +426,85 @@ function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
280
426
  return chunks;
281
427
  }
282
428
 
429
+ if (type === 'response.reasoning_summary_text.delta') {
430
+ ensureAssistantRoleChunk(state, chunks);
431
+ chunks.push(makeOpenAIChunk(state, { reasoning_content: String(event.delta || '') }, null));
432
+ return chunks;
433
+ }
434
+
435
+ if (type === 'response.reasoning_summary_text.done') {
436
+ if (typeof event.text === 'string' && event.text) {
437
+ ensureAssistantRoleChunk(state, chunks);
438
+ chunks.push(makeOpenAIChunk(state, { reasoning_content: event.text }, null));
439
+ }
440
+ return chunks;
441
+ }
442
+
283
443
  if (type === 'response.output_text.delta') {
444
+ const deltaText = String(event.delta || '');
445
+ if (!deltaText) return chunks;
284
446
  ensureAssistantRoleChunk(state, chunks);
285
447
  if (typeof event.item_id === 'string' && event.item_id.trim()) {
286
- state.textDeltaItemIds.add(event.item_id.trim());
448
+ const itemId = event.item_id.trim();
449
+ state.textOutputItemIds.add(itemId);
450
+ state.textOutputByItemId.set(itemId, `${state.textOutputByItemId.get(itemId) || ''}${deltaText}`);
287
451
  }
288
- chunks.push(makeOpenAIChunk(state, { content: String(event.delta || '') }, null));
452
+ state.hasTextOutput = true;
453
+ chunks.push(makeOpenAIChunk(state, { content: deltaText }, null));
289
454
  return chunks;
290
455
  }
291
456
 
292
457
  if (type === 'response.output_text.done') {
293
458
  const itemId = typeof event.item_id === 'string' ? event.item_id.trim() : '';
294
- if (itemId && !state.textDeltaItemIds.has(itemId) && typeof event.text === 'string' && event.text) {
459
+ const finalText = typeof event.text === 'string' ? event.text : '';
460
+ const missingText = itemId
461
+ ? getMissingSuffix(state.textOutputByItemId.get(itemId) || '', finalText)
462
+ : (state.hasTextOutput ? '' : finalText);
463
+ if (missingText) {
295
464
  ensureAssistantRoleChunk(state, chunks);
296
- chunks.push(makeOpenAIChunk(state, { content: event.text }, null));
465
+ chunks.push(makeOpenAIChunk(state, { content: missingText }, null));
466
+ if (itemId) {
467
+ state.textOutputItemIds.add(itemId);
468
+ state.textOutputByItemId.set(itemId, `${state.textOutputByItemId.get(itemId) || ''}${missingText}`);
469
+ }
470
+ state.hasTextOutput = true;
297
471
  }
298
472
  return chunks;
299
473
  }
300
474
 
475
+ if (type === 'response.content_part.done') {
476
+ const itemId = typeof event.item_id === 'string' ? event.item_id.trim() : '';
477
+ const finalText = event.part?.type === 'output_text' && typeof event.part?.text === 'string'
478
+ ? event.part.text
479
+ : '';
480
+ const missingText = itemId
481
+ ? getMissingSuffix(state.textOutputByItemId.get(itemId) || '', finalText)
482
+ : (state.hasTextOutput ? '' : finalText);
483
+ if (!missingText) return chunks;
484
+ ensureAssistantRoleChunk(state, chunks);
485
+ chunks.push(makeOpenAIChunk(state, { content: missingText }, null));
486
+ if (itemId) {
487
+ state.textOutputItemIds.add(itemId);
488
+ state.textOutputByItemId.set(itemId, `${state.textOutputByItemId.get(itemId) || ''}${missingText}`);
489
+ }
490
+ state.hasTextOutput = true;
491
+ return chunks;
492
+ }
493
+
301
494
  if (type === 'response.function_call_arguments.delta') {
495
+ const deltaArguments = String(event.delta || '');
496
+ if (!deltaArguments) return chunks;
302
497
  ensureAssistantRoleChunk(state, chunks);
303
498
  const toolIndex = resolveToolIndex(state, event);
304
499
  state.hasToolCalls = true;
500
+ state.toolCallArgumentsSeenByIndex.add(toolIndex);
501
+ state.toolCallArgumentsByIndex.set(toolIndex, `${state.toolCallArgumentsByIndex.get(toolIndex) || ''}${deltaArguments}`);
305
502
  chunks.push(makeOpenAIChunk(state, {
306
503
  tool_calls: [
307
504
  {
308
505
  index: toolIndex,
309
506
  function: {
310
- arguments: String(event.delta || '')
507
+ arguments: deltaArguments
311
508
  }
312
509
  }
313
510
  ]
@@ -316,15 +513,20 @@ function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
316
513
  }
317
514
 
318
515
  if (type === 'response.function_call_arguments.done') {
319
- ensureAssistantRoleChunk(state, chunks);
320
516
  const toolIndex = resolveToolIndex(state, event);
517
+ const finalArguments = String(event.arguments || '');
518
+ const missingArguments = getMissingSuffix(state.toolCallArgumentsByIndex.get(toolIndex) || '', finalArguments);
519
+ if (!missingArguments) return chunks;
520
+ ensureAssistantRoleChunk(state, chunks);
321
521
  state.hasToolCalls = true;
522
+ state.toolCallArgumentsSeenByIndex.add(toolIndex);
523
+ state.toolCallArgumentsByIndex.set(toolIndex, `${state.toolCallArgumentsByIndex.get(toolIndex) || ''}${missingArguments}`);
322
524
  chunks.push(makeOpenAIChunk(state, {
323
525
  tool_calls: [
324
526
  {
325
527
  index: toolIndex,
326
528
  function: {
327
- arguments: String(event.arguments || '')
529
+ arguments: missingArguments
328
530
  }
329
531
  }
330
532
  ]
@@ -332,8 +534,26 @@ function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
332
534
  return chunks;
333
535
  }
334
536
 
335
- if (type === 'response.completed' || type === 'response.failed') {
537
+ if (type === 'response.output_item.done') {
336
538
  updateStateFromResponse(state, event.response, fallbackModel);
539
+ const item = event.item;
540
+ if (!item || typeof item !== 'object') return chunks;
541
+
542
+ if (item.type === 'message' && item.role === 'assistant') {
543
+ emitFallbackTextChunk(state, item, chunks);
544
+ return chunks;
545
+ }
546
+
547
+ if (item.type === 'function_call') {
548
+ emitFallbackToolCallChunks(state, item, Number.isFinite(event.output_index) ? Number(event.output_index) : 0, chunks);
549
+ }
550
+
551
+ return chunks;
552
+ }
553
+
554
+ if (type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete') {
555
+ updateStateFromResponse(state, event.response, fallbackModel);
556
+ emitResponseOutputFallbacks(state, event.response, chunks);
337
557
  ensureAssistantRoleChunk(state, chunks);
338
558
  const responseUsage = toOpenAIUsage(event.response?.usage);
339
559
  const hasResponseToolCalls = Array.isArray(event.response?.output)
@@ -370,11 +590,43 @@ export function handleCodexStreamToOpenAI(response, { fallbackModel = 'unknown'
370
590
  toolCallByOutputIndex: new Map(),
371
591
  toolCallByItemId: new Map(),
372
592
  nextToolCallIndex: 0,
373
- textDeltaItemIds: new Set()
593
+ toolCallStartSentByIndex: new Set(),
594
+ toolCallArgumentsSeenByIndex: new Set(),
595
+ toolCallArgumentsByIndex: new Map(),
596
+ textOutputItemIds: new Set(),
597
+ textOutputByItemId: new Map(),
598
+ hasTextOutput: false
374
599
  };
375
600
 
376
601
  let buffer = '';
377
602
 
603
+ function processBlock(block, controller) {
604
+ if (!block || !block.trim()) return;
605
+
606
+ const parsedBlock = parseStreamBlock(block);
607
+ if (!parsedBlock.data) return;
608
+
609
+ if (parsedBlock.data === '[DONE]') {
610
+ if (!state.doneSent) {
611
+ state.doneSent = true;
612
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
613
+ }
614
+ return;
615
+ }
616
+
617
+ let payload;
618
+ try {
619
+ payload = JSON.parse(parsedBlock.data);
620
+ } catch {
621
+ return;
622
+ }
623
+
624
+ const chunks = eventToOpenAIChunks(payload, state, { fallbackModel });
625
+ for (const translated of chunks) {
626
+ controller.enqueue(encoder.encode(serializeOpenAIChunk(translated)));
627
+ }
628
+ }
629
+
378
630
  const transformStream = new TransformStream({
379
631
  transform(chunk, controller) {
380
632
  buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
@@ -383,34 +635,15 @@ export function handleCodexStreamToOpenAI(response, { fallbackModel = 'unknown'
383
635
  while ((boundaryIndex = buffer.indexOf('\n\n')) >= 0) {
384
636
  const block = buffer.slice(0, boundaryIndex);
385
637
  buffer = buffer.slice(boundaryIndex + 2);
386
- if (!block.trim()) continue;
387
-
388
- const parsedBlock = parseSseBlock(block);
389
- if (!parsedBlock.data) continue;
390
-
391
- if (parsedBlock.data === '[DONE]') {
392
- if (!state.doneSent) {
393
- state.doneSent = true;
394
- controller.enqueue(encoder.encode('data: [DONE]\n\n'));
395
- }
396
- continue;
397
- }
398
-
399
- let payload;
400
- try {
401
- payload = JSON.parse(parsedBlock.data);
402
- } catch {
403
- continue;
404
- }
405
-
406
- const chunks = eventToOpenAIChunks(payload, state, { fallbackModel });
407
- for (const translated of chunks) {
408
- controller.enqueue(encoder.encode(serializeOpenAIChunk(translated)));
409
- }
638
+ processBlock(block, controller);
410
639
  }
411
640
  },
412
641
 
413
642
  flush(controller) {
643
+ const remainder = buffer.trim();
644
+ if (remainder) {
645
+ processBlock(remainder, controller);
646
+ }
414
647
  if (state.doneSent) return;
415
648
  if (!state.roleSent) {
416
649
  controller.enqueue(encoder.encode(serializeOpenAIChunk(makeOpenAIChunk(state, { role: 'assistant' }, null))));