@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
@@ -30,7 +30,7 @@ function normalizeTextContent(content) {
30
30
  const text = [];
31
31
  for (const part of content) {
32
32
  if (!part || typeof part !== "object") continue;
33
- if ((part.type === "text" || part.type === "input_text") && typeof part.text === "string") {
33
+ if ((part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string") {
34
34
  text.push(part.text);
35
35
  }
36
36
  }
@@ -104,6 +104,179 @@ function convertOpenAIContentToClaudeBlocks(content) {
104
104
  return blocks;
105
105
  }
106
106
 
107
+ function appendClaudeMessage(messages, role, blocks) {
108
+ if (!Array.isArray(blocks) || blocks.length === 0) return;
109
+ const normalizedRole = role === "assistant" ? "assistant" : "user";
110
+ const lastMessage = messages[messages.length - 1];
111
+ if (lastMessage?.role === normalizedRole && Array.isArray(lastMessage.content)) {
112
+ lastMessage.content.push(...blocks);
113
+ return;
114
+ }
115
+ messages.push({
116
+ role: normalizedRole,
117
+ content: blocks
118
+ });
119
+ }
120
+
121
+ function convertResponsesMessageContentToClaudeBlocks(content) {
122
+ if (typeof content === "string") {
123
+ return content ? [{ type: "text", text: content }] : [];
124
+ }
125
+
126
+ if (!Array.isArray(content)) {
127
+ return [];
128
+ }
129
+
130
+ const blocks = [];
131
+ for (const part of content) {
132
+ if (!part || typeof part !== "object") continue;
133
+
134
+ if ((part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string") {
135
+ const cacheControl = cloneCacheControl(part.cache_control);
136
+ blocks.push({
137
+ type: "text",
138
+ text: part.text,
139
+ ...(cacheControl ? { cache_control: cacheControl } : {})
140
+ });
141
+ continue;
142
+ }
143
+
144
+ const rawImageUrl = typeof part.image_url === "string"
145
+ ? part.image_url
146
+ : part.image_url?.url;
147
+ if ((part.type === "image_url" || part.type === "input_image") && typeof rawImageUrl === "string" && rawImageUrl.trim()) {
148
+ const parsed = parseDataUrl(rawImageUrl.trim());
149
+ const cacheControl = cloneCacheControl(part.cache_control);
150
+ if (parsed) {
151
+ blocks.push({
152
+ type: "image",
153
+ source: {
154
+ type: "base64",
155
+ media_type: parsed.mediaType,
156
+ data: parsed.data
157
+ },
158
+ ...(cacheControl ? { cache_control: cacheControl } : {})
159
+ });
160
+ } else {
161
+ blocks.push({
162
+ type: "text",
163
+ text: `[image_url:${rawImageUrl.trim()}]`,
164
+ ...(cacheControl ? { cache_control: cacheControl } : {})
165
+ });
166
+ }
167
+ continue;
168
+ }
169
+
170
+ const rawFileUrl = typeof part.file_url === "string"
171
+ ? part.file_url
172
+ : (typeof part.url === "string" ? part.url : "");
173
+ if (part.type === "input_file" && rawFileUrl.trim()) {
174
+ const cacheControl = cloneCacheControl(part.cache_control);
175
+ blocks.push({
176
+ type: "text",
177
+ text: `[input_file:${rawFileUrl.trim()}]`,
178
+ ...(cacheControl ? { cache_control: cacheControl } : {})
179
+ });
180
+ }
181
+ }
182
+
183
+ return blocks;
184
+ }
185
+
186
+ function normalizeResponseInputArray(input) {
187
+ if (Array.isArray(input)) return input;
188
+ if (typeof input === "string" && input.trim()) {
189
+ return [{
190
+ type: "message",
191
+ role: "user",
192
+ content: [{ type: "input_text", text: input.trim() }]
193
+ }];
194
+ }
195
+ return [];
196
+ }
197
+
198
+ function extractResponsesSystem(body) {
199
+ const blocks = [];
200
+ const pushText = (text) => {
201
+ if (typeof text !== "string" || !text.trim()) return;
202
+ blocks.push({ type: "text", text: text.trim() });
203
+ };
204
+
205
+ if (typeof body?.instructions === "string" && body.instructions.trim()) {
206
+ pushText(body.instructions);
207
+ }
208
+
209
+ const explicitSystem = body?.system;
210
+ if (typeof explicitSystem === "string" && explicitSystem.trim()) {
211
+ pushText(explicitSystem);
212
+ } else if (Array.isArray(explicitSystem)) {
213
+ for (const item of explicitSystem) {
214
+ if ((item?.type === "text" || item?.type === "input_text" || item?.type === "output_text") && typeof item.text === "string") {
215
+ pushText(item.text);
216
+ }
217
+ }
218
+ }
219
+
220
+ for (const item of normalizeResponseInputArray(body?.input)) {
221
+ if (!item || typeof item !== "object") continue;
222
+ const role = String(item.role || "").trim().toLowerCase();
223
+ if (role !== "system" && role !== "developer") continue;
224
+ const text = normalizeTextContent(item.content);
225
+ if (text) pushText(text);
226
+ }
227
+
228
+ if (blocks.length === 0) return "";
229
+ return blocks.map((block) => block.text).join("\n").trim();
230
+ }
231
+
232
+ function convertOpenAIResponsesInput(input) {
233
+ const messages = [];
234
+ const items = normalizeResponseInputArray(input);
235
+ let generatedToolIndex = 0;
236
+
237
+ for (const item of items) {
238
+ if (!item || typeof item !== "object") continue;
239
+
240
+ const itemType = String(item.type || (item.role ? "message" : "")).trim().toLowerCase();
241
+ if (itemType === "message") {
242
+ const role = String(item.role || "user").trim().toLowerCase();
243
+ if (role === "system" || role === "developer") {
244
+ continue;
245
+ }
246
+ const blocks = convertResponsesMessageContentToClaudeBlocks(item.content);
247
+ if (blocks.length > 0) {
248
+ appendClaudeMessage(messages, role === "assistant" ? "assistant" : "user", blocks);
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if (itemType === "function_call") {
254
+ const callId = String(item.call_id || item.id || `tool_call_${generatedToolIndex += 1}`).trim();
255
+ const name = String(item.name || "tool").trim() || "tool";
256
+ appendClaudeMessage(messages, "assistant", [{
257
+ type: "tool_use",
258
+ id: callId,
259
+ name,
260
+ input: safeJsonParse(item.arguments, {})
261
+ }]);
262
+ continue;
263
+ }
264
+
265
+ if (itemType === "function_call_output") {
266
+ const toolUseId = String(item.call_id || item.tool_call_id || item.id || "").trim();
267
+ if (!toolUseId) continue;
268
+ const content = normalizeTextContent(item.output ?? item.content);
269
+ appendClaudeMessage(messages, "user", [{
270
+ type: "tool_result",
271
+ tool_use_id: toolUseId,
272
+ content
273
+ }]);
274
+ }
275
+ }
276
+
277
+ return messages;
278
+ }
279
+
107
280
  function mapToolChoice(choice) {
108
281
  if (!choice) return undefined;
109
282
  if (typeof choice === "string") {
@@ -251,21 +424,47 @@ function normalizeToolInputSchema(schema) {
251
424
  return normalized;
252
425
  }
253
426
 
427
+ function isOpenAIWebSearchToolType(type) {
428
+ const normalized = String(type || "").trim().toLowerCase();
429
+ if (!normalized) return false;
430
+ return normalized === "web_search" || normalized.startsWith("web_search_preview");
431
+ }
432
+
433
+ function convertOpenAIWebSearchTool(tool) {
434
+ if (!tool || typeof tool !== "object") return null;
435
+ const name = typeof tool.name === "string" && tool.name.trim()
436
+ ? tool.name.trim()
437
+ : "web_search";
438
+ const maxUses = Number(tool.max_uses);
439
+ return {
440
+ type: "web_search_20250305",
441
+ name,
442
+ ...(Number.isFinite(maxUses) && maxUses > 0 ? { max_uses: Math.trunc(maxUses) } : {})
443
+ };
444
+ }
445
+
254
446
  /**
255
447
  * Convert OpenAI chat completion request to Claude messages request.
256
448
  */
257
449
  export function openAIToClaudeRequest(model, body, stream = false) {
258
- const messages = Array.isArray(body?.messages) ? body.messages : [];
450
+ const isResponsesPayload = body?.input !== undefined || body?.instructions !== undefined;
451
+ const messages = isResponsesPayload
452
+ ? convertOpenAIResponsesInput(body?.input)
453
+ : convertOpenAIMessages(Array.isArray(body?.messages) ? body.messages : []);
259
454
  const result = {
260
455
  model,
261
- messages: convertOpenAIMessages(messages),
456
+ messages,
262
457
  stream: Boolean(stream),
263
- max_tokens: Number.isFinite(body?.max_tokens)
458
+ max_tokens: Number.isFinite(body?.max_output_tokens)
459
+ ? Number(body.max_output_tokens)
460
+ : (Number.isFinite(body?.max_tokens)
264
461
  ? Number(body.max_tokens)
265
- : (Number.isFinite(body?.max_completion_tokens) ? Number(body.max_completion_tokens) : DEFAULT_MAX_TOKENS)
462
+ : (Number.isFinite(body?.max_completion_tokens) ? Number(body.max_completion_tokens) : DEFAULT_MAX_TOKENS))
266
463
  };
267
464
 
268
- const system = normalizeSystemText(messages, body?.system);
465
+ const system = isResponsesPayload
466
+ ? extractResponsesSystem(body)
467
+ : normalizeSystemText(Array.isArray(body?.messages) ? body.messages : [], body?.system);
269
468
  if (system && ((Array.isArray(system) && system.length > 0) || (typeof system === "string" && system.trim()))) {
270
469
  result.system = system;
271
470
  }
@@ -278,6 +477,9 @@ export function openAIToClaudeRequest(model, body, stream = false) {
278
477
  result.tools = body.tools
279
478
  .map((tool) => {
280
479
  if (!tool || typeof tool !== "object") return null;
480
+ if (isOpenAIWebSearchToolType(tool.type)) {
481
+ return convertOpenAIWebSearchTool(tool);
482
+ }
281
483
  if (tool.type === "function" && tool.function) {
282
484
  const cacheControl = cloneCacheControl(tool.cache_control || tool.function?.cache_control);
283
485
  return {
@@ -287,6 +489,15 @@ export function openAIToClaudeRequest(model, body, stream = false) {
287
489
  ...(cacheControl ? { cache_control: cacheControl } : {})
288
490
  };
289
491
  }
492
+ if (tool.type === "function" && tool.name) {
493
+ const cacheControl = cloneCacheControl(tool.cache_control);
494
+ return {
495
+ name: tool.name,
496
+ description: tool.description,
497
+ input_schema: normalizeToolInputSchema(tool.parameters),
498
+ ...(cacheControl ? { cache_control: cacheControl } : {})
499
+ };
500
+ }
290
501
  if (tool.name) {
291
502
  const cacheControl = cloneCacheControl(tool.cache_control);
292
503
  return {
@@ -26,26 +26,7 @@ export function openaiToClaudeResponse(chunk, state) {
26
26
  }
27
27
 
28
28
  // First chunk - send message_start
29
- if (!state.messageStartSent) {
30
- state.messageStartSent = true;
31
- state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
32
- state.model = chunk.model || "unknown";
33
- state.nextBlockIndex = 0;
34
-
35
- results.push({
36
- type: "message_start",
37
- message: {
38
- id: state.messageId,
39
- type: "message",
40
- role: "assistant",
41
- model: state.model,
42
- content: [],
43
- stop_reason: null,
44
- stop_sequence: null,
45
- usage: { input_tokens: 0, output_tokens: 0 }
46
- }
47
- });
48
- }
29
+ ensureMessageStart(state, results, chunk);
49
30
 
50
31
  // Handle thinking/reasoning content
51
32
  const reasoningContent = delta?.reasoning_content || delta?.reasoning;
@@ -70,7 +51,8 @@ export function openaiToClaudeResponse(chunk, state) {
70
51
  }
71
52
 
72
53
  // Handle regular content
73
- if (delta?.content) {
54
+ const textDelta = normalizeTextDelta(delta?.content);
55
+ if (textDelta) {
74
56
  stopThinkingBlock(state, results);
75
57
 
76
58
  if (!state.textBlockStarted) {
@@ -87,7 +69,7 @@ export function openaiToClaudeResponse(chunk, state) {
87
69
  results.push({
88
70
  type: "content_block_delta",
89
71
  index: state.textBlockIndex,
90
- delta: { type: "text_delta", text: delta.content }
72
+ delta: { type: "text_delta", text: textDelta }
91
73
  });
92
74
  }
93
75
 
@@ -95,32 +77,12 @@ export function openaiToClaudeResponse(chunk, state) {
95
77
  if (delta?.tool_calls) {
96
78
  for (const tc of delta.tool_calls) {
97
79
  const idx = tc.index ?? 0;
98
-
99
- if (tc.id) {
100
- stopThinkingBlock(state, results);
101
- stopTextBlock(state, results);
102
-
103
- const toolBlockIndex = state.nextBlockIndex++;
104
- state.toolCalls.set(idx, {
105
- id: tc.id,
106
- name: tc.function?.name || "",
107
- blockIndex: toolBlockIndex
108
- });
109
-
110
- results.push({
111
- type: "content_block_start",
112
- index: toolBlockIndex,
113
- content_block: {
114
- type: "tool_use",
115
- id: tc.id,
116
- name: tc.function?.name || "",
117
- input: {}
118
- }
119
- });
120
- }
80
+ const toolInfo = ensureToolUseBlock(state, results, idx, {
81
+ id: tc.id,
82
+ name: tc.function?.name
83
+ });
121
84
 
122
85
  if (tc.function?.arguments) {
123
- const toolInfo = state.toolCalls.get(idx);
124
86
  if (toolInfo) {
125
87
  results.push({
126
88
  type: "content_block_delta",
@@ -132,31 +94,215 @@ export function openaiToClaudeResponse(chunk, state) {
132
94
  }
133
95
  }
134
96
 
97
+ if (delta?.function_call && typeof delta.function_call === "object") {
98
+ const toolInfo = ensureToolUseBlock(state, results, 0, {
99
+ id: delta.function_call.id,
100
+ name: delta.function_call.name
101
+ });
102
+ if (toolInfo && delta.function_call.arguments) {
103
+ results.push({
104
+ type: "content_block_delta",
105
+ index: toolInfo.blockIndex,
106
+ delta: { type: "input_json_delta", partial_json: delta.function_call.arguments }
107
+ });
108
+ }
109
+ }
110
+
111
+ emitFinalChoiceMessageFallback(choice?.message, state, results);
112
+
135
113
  // Finish
136
114
  if (choice.finish_reason) {
137
- stopThinkingBlock(state, results);
138
- stopTextBlock(state, results);
115
+ state.finishReason = choice.finish_reason;
116
+ results.push(...finalizeOpenAIToClaudeStream(state));
117
+ }
118
+
119
+ return results.length > 0 ? results : null;
120
+ }
121
+
122
+ function normalizeTextDelta(content) {
123
+ if (typeof content === "string") return content;
124
+ if (!Array.isArray(content)) return "";
125
+ return content
126
+ .map((part) => {
127
+ if (typeof part === "string") return part;
128
+ if (!part || typeof part !== "object") return "";
129
+ if ((part.type === "text" || part.type === "output_text") && typeof part.text === "string") {
130
+ return part.text;
131
+ }
132
+ return "";
133
+ })
134
+ .join("");
135
+ }
136
+
137
+ function ensureToolUseBlock(state, results, index, { id, name } = {}) {
138
+ if (!state?.toolCalls || !(state.toolCalls instanceof Map)) return null;
139
+ const normalizedIndex = Number.isFinite(index) ? Number(index) : 0;
140
+ const existing = state.toolCalls.get(normalizedIndex);
141
+ if (existing) return existing;
142
+
143
+ const toolName = typeof name === "string" && name.trim() ? name.trim() : "tool";
144
+ const toolId = typeof id === "string" && id.trim()
145
+ ? id.trim()
146
+ : `tool_${state.messageId || "call"}_${normalizedIndex}`;
147
+
148
+ stopThinkingBlock(state, results);
149
+ stopTextBlock(state, results);
150
+
151
+ const toolBlockIndex = state.nextBlockIndex++;
152
+ const toolInfo = {
153
+ id: toolId,
154
+ name: toolName,
155
+ blockIndex: toolBlockIndex,
156
+ closed: false
157
+ };
158
+ state.toolCalls.set(normalizedIndex, toolInfo);
159
+
160
+ results.push({
161
+ type: "content_block_start",
162
+ index: toolBlockIndex,
163
+ content_block: {
164
+ type: "tool_use",
165
+ id: toolId,
166
+ name: toolName,
167
+ input: {}
168
+ }
169
+ });
170
+
171
+ return toolInfo;
172
+ }
173
+
174
+ function normalizeMessageToolCalls(message) {
175
+ const toolCalls = Array.isArray(message?.tool_calls)
176
+ ? message.tool_calls.filter((call) => call && typeof call === "object")
177
+ : [];
178
+
179
+ if (message?.function_call && typeof message.function_call === "object") {
180
+ toolCalls.push({
181
+ id: message.function_call.id,
182
+ function: {
183
+ name: message.function_call.name,
184
+ arguments: message.function_call.arguments
185
+ }
186
+ });
187
+ }
139
188
 
140
- // Stop all tool blocks
141
- for (const [, toolInfo] of state.toolCalls) {
189
+ return toolCalls;
190
+ }
191
+
192
+ function emitTextDelta(text, state, results) {
193
+ if (!text) return;
194
+ stopThinkingBlock(state, results);
195
+
196
+ if (!state.textBlockStarted) {
197
+ state.textBlockIndex = state.nextBlockIndex++;
198
+ state.textBlockStarted = true;
199
+ state.textBlockClosed = false;
200
+ results.push({
201
+ type: "content_block_start",
202
+ index: state.textBlockIndex,
203
+ content_block: { type: "text", text: "" }
204
+ });
205
+ }
206
+
207
+ results.push({
208
+ type: "content_block_delta",
209
+ index: state.textBlockIndex,
210
+ delta: { type: "text_delta", text }
211
+ });
212
+ }
213
+
214
+ function emitFinalChoiceMessageFallback(message, state, results) {
215
+ if (!message || typeof message !== "object") return;
216
+
217
+ const hasTextOutput = state.textBlockStarted || state.textBlockClosed;
218
+ if (!hasTextOutput) {
219
+ const fallbackText = normalizeTextDelta(message.content)
220
+ || (typeof message.refusal === "string" ? message.refusal : "");
221
+ emitTextDelta(fallbackText, state, results);
222
+ }
223
+
224
+ const hasToolOutput = state.toolCalls instanceof Map && state.toolCalls.size > 0;
225
+ if (hasToolOutput) return;
226
+
227
+ const toolCalls = normalizeMessageToolCalls(message);
228
+ for (let index = 0; index < toolCalls.length; index += 1) {
229
+ const toolCall = toolCalls[index];
230
+ if (!toolCall || typeof toolCall !== "object") continue;
231
+ const toolInfo = ensureToolUseBlock(state, results, toolCall.index ?? index, {
232
+ id: toolCall.id,
233
+ name: toolCall.function?.name
234
+ });
235
+ if (toolInfo && toolCall.function?.arguments) {
142
236
  results.push({
143
- type: "content_block_stop",
144
- index: toolInfo.blockIndex
237
+ type: "content_block_delta",
238
+ index: toolInfo.blockIndex,
239
+ delta: { type: "input_json_delta", partial_json: toolCall.function.arguments }
145
240
  });
146
241
  }
242
+ }
243
+ }
147
244
 
148
- state.finishReason = choice.finish_reason;
149
-
150
- const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
245
+ function ensureMessageStart(state, results, chunk = undefined) {
246
+ if (state.messageStartSent) return;
247
+ state.messageStartSent = true;
248
+ state.messageId = chunk?.id?.replace("chatcmpl-", "") || state.messageId || `msg_${Date.now()}`;
249
+ state.model = chunk?.model || state.model || "unknown";
250
+ state.nextBlockIndex = Number.isFinite(state.nextBlockIndex) ? state.nextBlockIndex : 0;
251
+
252
+ results.push({
253
+ type: "message_start",
254
+ message: {
255
+ id: state.messageId,
256
+ type: "message",
257
+ role: "assistant",
258
+ model: state.model,
259
+ content: [],
260
+ stop_reason: null,
261
+ stop_sequence: null,
262
+ usage: { input_tokens: 0, output_tokens: 0 }
263
+ }
264
+ });
265
+ }
266
+
267
+ export function finalizeOpenAIToClaudeStream(state, { force = false } = {}) {
268
+ const results = [];
269
+ if (!state || (!state.messageStartSent && !force)) return results;
270
+
271
+ if (!state.messageStartSent) {
272
+ ensureMessageStart(state, results);
273
+ }
274
+
275
+ stopThinkingBlock(state, results);
276
+ stopTextBlock(state, results);
277
+
278
+ for (const [, toolInfo] of state.toolCalls) {
279
+ if (toolInfo?.closed) continue;
280
+ results.push({
281
+ type: "content_block_stop",
282
+ index: toolInfo.blockIndex
283
+ });
284
+ toolInfo.closed = true;
285
+ }
286
+
287
+ if (!state.messageDeltaSent) {
288
+ const hasToolCalls = state.toolCalls instanceof Map && state.toolCalls.size > 0;
289
+ const normalizedFinishReason = hasToolCalls && (!state.finishReason || state.finishReason === "stop")
290
+ ? "tool_calls"
291
+ : (state.finishReason || "stop");
151
292
  results.push({
152
293
  type: "message_delta",
153
- delta: { stop_reason: convertFinishReason(choice.finish_reason) },
154
- usage: finalUsage
294
+ delta: { stop_reason: convertFinishReason(normalizedFinishReason) },
295
+ usage: state.usage || { input_tokens: 0, output_tokens: 0 }
155
296
  });
297
+ state.messageDeltaSent = true;
298
+ }
299
+
300
+ if (!state.messageStopSent) {
156
301
  results.push({ type: "message_stop" });
302
+ state.messageStopSent = true;
157
303
  }
158
304
 
159
- return results.length > 0 ? results : null;
305
+ return results;
160
306
  }
161
307
 
162
308
  /**
@@ -169,6 +315,7 @@ function stopThinkingBlock(state, results) {
169
315
  index: state.thinkingBlockIndex
170
316
  });
171
317
  state.thinkingBlockStarted = false;
318
+ state.thinkingBlockIndex = null;
172
319
  }
173
320
 
174
321
  /**
@@ -191,6 +338,7 @@ function convertFinishReason(reason) {
191
338
  switch (reason) {
192
339
  case "stop": return "end_turn";
193
340
  case "length": return "max_tokens";
341
+ case "function_call": return "tool_use";
194
342
  case "tool_calls": return "tool_use";
195
343
  default: return "end_turn";
196
344
  }