@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 +15 -0
- package/package.json +2 -2
- package/src/providers/openai-codex-responses.ts +1 -1
- package/src/providers/openai-completions-compat.ts +3 -3
- package/src/providers/openai-responses-shared.ts +1 -1
- package/src/providers/transform-messages.ts +67 -63
- package/src/utils/validation.ts +25 -17
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.
|
|
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.
|
|
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"
|
|
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
|
|
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:
|
|
95
|
-
requiresAssistantContentForToolCalls:
|
|
94
|
+
requiresReasoningContentForToolCalls: isKimiModel,
|
|
95
|
+
requiresAssistantContentForToolCalls: isKimiModel,
|
|
96
96
|
openRouterRouting: undefined,
|
|
97
97
|
vercelGatewayRouting: undefined,
|
|
98
98
|
supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
|
|
@@ -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
|
-
//
|
|
127
|
+
// and preserve aborted/errored tool results when they were already persisted.
|
|
128
128
|
const result: Message[] = [];
|
|
129
129
|
let pendingToolCalls: ToolCall[] = [];
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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):
|
|
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):
|
|
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
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
514
|
-
if (
|
|
515
|
-
normalizedArgs =
|
|
519
|
+
const initialNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs);
|
|
520
|
+
if (initialNormalization.changed) {
|
|
521
|
+
normalizedArgs = initialNormalization.value;
|
|
516
522
|
changed = true;
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|