@oh-my-pi/pi-ai 13.12.10 → 13.13.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.13.2] - 2026-03-18
6
+ ### Changed
7
+
8
+ - Modified tool result handling for aborted assistant messages to preserve existing tool results when already recorded, instead of always replacing them with synthetic 'aborted' results
9
+
10
+ ## [13.13.0] - 2026-03-18
11
+ ### Changed
12
+
13
+ - Changed tool argument validation to always normalize optional null values before type coercion, ensuring consistent handling of LLM-generated 'null' strings
14
+
15
+ ### Fixed
16
+
17
+ - Fixed tool argument validation to properly handle string 'null' values from LLMs on optional fields by stripping them during normalization
18
+ - Improved type safety of `validateToolCall` and `validateToolArguments` functions by returning properly typed `ToolCall["arguments"]` instead of `any`
19
+
5
20
  ## [13.12.9] - 2026-03-17
6
21
  ### Changed
7
22
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "13.12.10",
4
+ "version": "13.13.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,7 +41,7 @@
41
41
  "@aws-sdk/client-bedrock-runtime": "^3",
42
42
  "@bufbuild/protobuf": "^2.11",
43
43
  "@google/genai": "^1.43",
44
- "@oh-my-pi/pi-utils": "13.12.10",
44
+ "@oh-my-pi/pi-utils": "13.13.2",
45
45
  "@sinclair/typebox": "^0.34",
46
46
  "@smithy/node-http-handler": "^4.4",
47
47
  "ajv": "^8.18",
@@ -2134,7 +2134,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2134
2134
  } satisfies ResponseOutputMessage);
2135
2135
  continue;
2136
2136
  }
2137
- if (block.type === "toolCall" && msg.stopReason !== "error") {
2137
+ if (block.type === "toolCall") {
2138
2138
  const toolCall = block as ToolCall;
2139
2139
  const normalized = normalizeResponsesToolCallId(toolCall.id);
2140
2140
  outputItems.push({
@@ -43,7 +43,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
43
43
 
44
44
  const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
45
45
  const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
46
- const isOpenRouterKimi = provider === "openrouter" && model.id.includes("moonshotai/kimi");
46
+ const isKimiModel = model.id.includes("moonshotai/kimi");
47
47
  const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
48
48
  const isQwen = model.id.toLowerCase().includes("qwen");
49
49
 
@@ -91,8 +91,8 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
91
91
  requiresMistralToolIds: isMistral,
92
92
  thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai",
93
93
  reasoningContentField: "reasoning_content",
94
- requiresReasoningContentForToolCalls: isOpenRouterKimi,
95
- requiresAssistantContentForToolCalls: isOpenRouterKimi,
94
+ requiresReasoningContentForToolCalls: isKimiModel,
95
+ requiresAssistantContentForToolCalls: isKimiModel,
96
96
  openRouterRouting: undefined,
97
97
  vercelGatewayRouting: undefined,
98
98
  supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
@@ -134,7 +134,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
134
134
  continue;
135
135
  }
136
136
 
137
- if (block.type !== "toolCall" || assistantMsg.stopReason === "error") {
137
+ if (block.type !== "toolCall") {
138
138
  continue;
139
139
  }
140
140
 
@@ -124,101 +124,105 @@ export function transformMessages<TApi extends Api>(
124
124
  });
125
125
 
126
126
  // Second pass: insert synthetic empty tool results for orphaned tool calls
127
- // This preserves thinking signatures and satisfies API requirements
127
+ // and preserve aborted/errored tool results when they were already persisted.
128
128
  const result: Message[] = [];
129
129
  let pendingToolCalls: ToolCall[] = [];
130
- // Track tool call status: whether resolved (has result) or aborted (skip real results)
130
+ let pendingAbortedToolCalls = new Map<string, ToolCall>();
131
+ let pendingAbortedTimestamp: number | undefined;
132
+ // Track tool call status: whether resolved (has result) or aborted (synthetic result injected, skip later real results)
131
133
  const toolCallStatus = new Map<string, ToolCallStatus>();
132
134
 
135
+ const flushPendingToolCalls = (timestamp: number): void => {
136
+ if (pendingToolCalls.length === 0) return;
137
+ for (const tc of pendingToolCalls) {
138
+ if (!toolCallStatus.has(tc.id)) {
139
+ result.push({
140
+ role: "toolResult",
141
+ toolCallId: tc.id,
142
+ toolName: tc.name,
143
+ content: [{ type: "text", text: "No result provided" }],
144
+ isError: true,
145
+ timestamp,
146
+ } as ToolResultMessage);
147
+ toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
148
+ }
149
+ }
150
+ pendingToolCalls = [];
151
+ };
152
+
153
+ const flushPendingAbortedToolCalls = (): void => {
154
+ if (pendingAbortedTimestamp === undefined) return;
155
+ for (const tc of pendingAbortedToolCalls.values()) {
156
+ if (!toolCallStatus.has(tc.id)) {
157
+ result.push({
158
+ role: "toolResult",
159
+ toolCallId: tc.id,
160
+ toolName: tc.name,
161
+ content: [{ type: "text", text: "aborted" }],
162
+ isError: true,
163
+ timestamp: pendingAbortedTimestamp,
164
+ } as ToolResultMessage);
165
+ toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
166
+ }
167
+ }
168
+ result.push({
169
+ role: "developer",
170
+ content: turnAbortedGuidance,
171
+ timestamp: pendingAbortedTimestamp + 1,
172
+ } as DeveloperMessage);
173
+ pendingAbortedToolCalls = new Map();
174
+ pendingAbortedTimestamp = undefined;
175
+ };
176
+
133
177
  for (let i = 0; i < transformed.length; i++) {
134
178
  const msg = transformed[i];
179
+ const messageTimestamp = "timestamp" in msg && typeof msg.timestamp === "number" ? msg.timestamp : Date.now();
135
180
 
136
181
  if (msg.role === "assistant") {
137
- // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
138
- if (pendingToolCalls.length > 0) {
139
- for (const tc of pendingToolCalls) {
140
- if (!toolCallStatus.has(tc.id)) {
141
- result.push({
142
- role: "toolResult",
143
- toolCallId: tc.id,
144
- toolName: tc.name,
145
- content: [{ type: "text", text: "No result provided" }],
146
- isError: true,
147
- timestamp: Date.now(),
148
- } as ToolResultMessage);
149
- toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
150
- }
151
- }
152
- pendingToolCalls = [];
153
- }
182
+ flushPendingToolCalls(messageTimestamp);
183
+ flushPendingAbortedToolCalls();
154
184
 
155
- // For errored/aborted assistant messages: keep tool calls intact,
156
- // inject synthetic "aborted" results, and add guidance marker.
157
- // This preserves structure so the model knows what was attempted.
158
185
  const assistantMsg = msg as AssistantMessage;
159
186
  const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
160
187
 
161
188
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
162
- // Push the assistant message with tool calls intact
189
+ // Keep the assistant message with tool calls intact. If real tool results follow, preserve them;
190
+ // otherwise synthesize aborted results before the next turn boundary.
163
191
  result.push(msg);
164
-
165
- // Inject synthetic "aborted" results for each tool call
166
- for (const tc of toolCalls) {
167
- toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
168
- result.push({
169
- role: "toolResult",
170
- toolCallId: tc.id,
171
- toolName: tc.name,
172
- content: [{ type: "text", text: "aborted" }],
173
- isError: true,
174
- timestamp: assistantMsg.timestamp,
175
- } as ToolResultMessage);
176
- }
177
-
178
- // Inject turn-aborted guidance marker as developer message
179
- result.push({
180
- role: "developer",
181
- content: turnAbortedGuidance,
182
- timestamp: assistantMsg.timestamp + 1,
183
- } as DeveloperMessage);
184
-
192
+ pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const));
193
+ pendingAbortedTimestamp = assistantMsg.timestamp;
185
194
  continue;
186
195
  }
187
196
 
188
- // Track tool calls from this normal assistant message
189
197
  if (toolCalls.length > 0) {
190
198
  pendingToolCalls = toolCalls;
191
199
  }
192
200
 
193
201
  result.push(msg);
194
202
  } else if (msg.role === "toolResult") {
195
- // Skip tool results for aborted tool calls (we already injected synthetic ones)
203
+ if (pendingAbortedToolCalls.has(msg.toolCallId)) {
204
+ pendingAbortedToolCalls.delete(msg.toolCallId);
205
+ toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
206
+ result.push(msg);
207
+ continue;
208
+ }
209
+
196
210
  if (toolCallStatus.get(msg.toolCallId) === ToolCallStatus.Aborted) continue;
197
211
  toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
198
212
  result.push(msg);
199
213
  } else if (msg.role === "user" || msg.role === "developer") {
200
- // User/developer message interrupts tool flow - insert synthetic results for orphaned calls
201
- if (pendingToolCalls.length > 0) {
202
- for (const tc of pendingToolCalls) {
203
- if (!toolCallStatus.has(tc.id)) {
204
- result.push({
205
- role: "toolResult",
206
- toolCallId: tc.id,
207
- toolName: tc.name,
208
- content: [{ type: "text", text: "No result provided" }],
209
- isError: true,
210
- timestamp: Date.now(),
211
- } as ToolResultMessage);
212
- toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
213
- }
214
- }
215
- pendingToolCalls = [];
216
- }
214
+ flushPendingToolCalls(messageTimestamp);
215
+ flushPendingAbortedToolCalls();
217
216
  result.push(msg);
218
217
  } else {
218
+ flushPendingToolCalls(messageTimestamp);
219
+ flushPendingAbortedToolCalls();
219
220
  result.push(msg);
220
221
  }
221
222
  }
222
223
 
224
+ flushPendingToolCalls(Date.now());
225
+ flushPendingAbortedToolCalls();
226
+
223
227
  return result;
224
228
  }
@@ -172,7 +172,14 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value:
172
172
 
173
173
  try {
174
174
  const parsed = JSON.parse(trimmed) as unknown;
175
- // Only accept if the parsed type matches what the schema expects
175
+ // If the string was "null", we parsed it to actual null.
176
+ // Accept this even if null isn't in expectedTypes - the LLM meant "no value".
177
+ // normalizeOptionalNullsForSchema will strip it from optional fields, and
178
+ // AJV will correctly error on required fields.
179
+ if (parsed === null && trimmed === "null") {
180
+ return { value: null, changed: true };
181
+ }
182
+ // For non-null values, only accept if the parsed type matches what the schema expects
176
183
  if (matchesExpectedType(parsed, expectedTypes)) {
177
184
  return { value: parsed, changed: true };
178
185
  }
@@ -378,7 +385,9 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val
378
385
  if (!(key in nextValue)) continue;
379
386
  const currentValue = nextValue[key];
380
387
 
381
- if (currentValue === null && !required.has(key)) {
388
+ // Strip null and the string "null" from optional fields.
389
+ // The LLM sometimes outputs string "null" to mean "no value".
390
+ if ((currentValue === null || currentValue === "null") && !required.has(key)) {
382
391
  if (!changed) {
383
392
  nextValue = { ...nextValue };
384
393
  changed = true;
@@ -386,7 +395,6 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val
386
395
  delete nextValue[key];
387
396
  continue;
388
397
  }
389
-
390
398
  const normalized = normalizeOptionalNullsForSchema(propertySchema, currentValue);
391
399
  if (!normalized.changed) continue;
392
400
 
@@ -482,7 +490,7 @@ const MAX_TYPE_COERCION_PASSES = 5;
482
490
  * @returns The validated arguments
483
491
  * @throws Error if tool is not found or validation fails
484
492
  */
485
- export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
493
+ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["arguments"] {
486
494
  const tool = tools.find(t => t.name === toolCall.name);
487
495
  if (!tool) {
488
496
  throw new Error(`Tool "${toolCall.name}" not found`);
@@ -497,26 +505,26 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
497
505
  * @returns The validated arguments
498
506
  * @throws Error with formatted message if validation fails
499
507
  */
500
- export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
508
+ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] {
501
509
  const originalArgs = toolCall.arguments;
502
510
 
503
511
  const validate = compileSchema(tool.parameters);
504
512
 
505
- // Validate the arguments
506
- if (validate(originalArgs)) {
507
- return originalArgs;
508
- }
509
-
513
+ // Always normalize first - strip null and string "null" from optional fields.
514
+ // This handles LLM outputting string "null" to mean "no value" even when
515
+ // validation would pass (e.g., optional string field where "null" is a valid string).
510
516
  let normalizedArgs: unknown = originalArgs;
511
517
  let changed = false;
512
518
 
513
- const optionalNullNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs);
514
- if (optionalNullNormalization.changed) {
515
- normalizedArgs = optionalNullNormalization.value;
519
+ const initialNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs);
520
+ if (initialNormalization.changed) {
521
+ normalizedArgs = initialNormalization.value;
516
522
  changed = true;
517
- if (validate(normalizedArgs)) {
518
- return normalizedArgs;
519
- }
523
+ }
524
+
525
+ // Validate after normalization
526
+ if (validate(normalizedArgs)) {
527
+ return normalizedArgs as ToolCall["arguments"];
520
528
  }
521
529
 
522
530
  for (let pass = 0; pass < MAX_TYPE_COERCION_PASSES; pass += 1) {
@@ -532,7 +540,7 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
532
540
  }
533
541
 
534
542
  if (validate(normalizedArgs)) {
535
- return normalizedArgs;
543
+ return normalizedArgs as ToolCall["arguments"];
536
544
  }
537
545
  }
538
546