@oh-my-pi/pi-ai 15.1.4 → 15.1.6
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 +7 -0
- package/dist/types/utils/schema/types.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +18 -0
- package/package.json +2 -2
- package/src/cli.ts +20 -280
- package/src/utils/schema/normalize.ts +10 -1
- package/src/utils/schema/types.ts +6 -0
- package/src/utils/schema/wire.ts +84 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.1.6] - 2026-05-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed `{}` (empty JSON Schema, the wire representation of `z.unknown()`) being passed verbatim to grammar-constrained samplers (llama.cpp, etc.) in `additionalProperties`, `items`, and other schema-valued positions across **every provider** (OpenAI, Anthropic, Google, Ollama, Bedrock, Cursor). Grammar builders treat `{}` as "generate an empty object" rather than "any JSON value", causing open-typed fields (e.g. `extra.title` from `z.record(z.string(), z.unknown())`) to always emit `{}` instead of the intended string/number/etc. `toolWireSchema` now applies a new `normalizeEmptySchemas` pass (exported) to both the Zod and TypeBox/raw-JSON-Schema branches, converting `{}` → `true` (semantically identical per JSON Schema draft 2020-12 §4.3.1) in all schema-valued positions. Strict-mode opt-out is preserved across all providers: OpenAI's `hasUnrepresentableStrictObjectMap` hits the `=== true` branch instead of the `isJsonObject({})` branch (same result); Anthropic's `normalizeAnthropicStrictSchemaNode` opts out via `additionalProperties !== false` (still true for `true`); Google's `normalizeSchemaForGoogle` strips `additionalProperties` regardless (pre-existing). ([#1179](https://github.com/can1357/oh-my-pi/issues/1179))
|
|
10
|
+
- Fixed `pi-ai login <provider>` crashing with `Unknown provider` for providers that only the `auth-storage` `login()` switch knew about (perplexity, alibaba-coding-plan, gitlab-duo, huggingface, opencode-zen/go, lm-studio, ollama, cerebras, fireworks, qianfan, synthetic, venice, litellm, moonshot, together, cloudflare/vercel ai gateways, vllm, qwen-portal, nvidia, xiaomi, and any custom OAuth provider). The CLI now delegates to `SqliteAuthCredentialStore.login()` instead of duplicating a smaller switch, so the auth-broker `omp auth-broker login <provider>` flow works for every registered OAuth provider.
|
|
11
|
+
|
|
5
12
|
## [15.1.4] - 2026-05-19
|
|
6
13
|
### Changed
|
|
7
14
|
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export type JsonObject = Record<string, unknown>;
|
|
2
2
|
export declare function isJsonObject(value: unknown): value is JsonObject;
|
|
3
|
+
/** True when `value` is a plain JSON object with no own enumerable keys. */
|
|
4
|
+
export declare function isJsonObjectEmpty(value: JsonObject): boolean;
|
|
@@ -26,11 +26,29 @@ import type { Tool } from "../../types";
|
|
|
26
26
|
* impostors do not.
|
|
27
27
|
*/
|
|
28
28
|
export declare function isZodSchema(value: unknown): value is ZodType;
|
|
29
|
+
/**
|
|
30
|
+
* Normalize `{}` (empty JSON Schema = `z.unknown()` / unconstrained value) to
|
|
31
|
+
* boolean `true` in every schema-valued position. JSON Schema draft 2020-12
|
|
32
|
+
* §4.3.1: `{}` and `true` are semantically equivalent ("any JSON value").
|
|
33
|
+
* Grammar-constrained samplers (llama.cpp, etc.) treat the object form as
|
|
34
|
+
* "generate an empty object" rather than "any JSON value", causing open-typed
|
|
35
|
+
* fields like `extra.title` (from `z.record(z.string(), z.unknown())`) to
|
|
36
|
+
* always emit `{}` instead of the intended string/number/etc. (issue #1179).
|
|
37
|
+
*
|
|
38
|
+
* Mutates in place. Provider-agnostic — applied to every tool wire schema so
|
|
39
|
+
* Anthropic, Google, OpenAI, Ollama, Bedrock, and Cursor all see the
|
|
40
|
+
* normalized form, regardless of whether the source was Zod or TypeBox.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalizeEmptySchemas(node: unknown): void;
|
|
29
43
|
/** Convert a Zod schema into the JSON Schema shape providers consume. */
|
|
30
44
|
export declare function zodToWireSchema(schema: ZodType): Record<string, unknown>;
|
|
31
45
|
/**
|
|
32
46
|
* Resolve a tool's parameters to a JSON Schema object suitable for sending
|
|
33
47
|
* over the wire. Zod schemas are converted (and cached); legacy TypeBox / raw
|
|
34
48
|
* JSON Schema parameters are upgraded to draft 2020-12 (and cached).
|
|
49
|
+
*
|
|
50
|
+
* Both branches finish with `normalizeEmptySchemas` so every provider —
|
|
51
|
+
* OpenAI, Anthropic, Google, Ollama, Bedrock, Cursor — sees `{}` normalized
|
|
52
|
+
* to `true` in schema-valued positions (issue #1179).
|
|
35
53
|
*/
|
|
36
54
|
export declare function toolWireSchema(tool: Tool): Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "15.1.
|
|
4
|
+
"version": "15.1.6",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@anthropic-ai/sdk": "^0.94.0",
|
|
45
45
|
"@bufbuild/protobuf": "^2.12.0",
|
|
46
|
-
"@oh-my-pi/pi-utils": "15.1.
|
|
46
|
+
"@oh-my-pi/pi-utils": "15.1.6",
|
|
47
47
|
"openai": "^6.36.0",
|
|
48
48
|
"partial-json": "^0.1.7",
|
|
49
49
|
"zod": "4.4.3"
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import * as readline from "node:readline";
|
|
3
|
-
import { SqliteAuthCredentialStore } from "./auth-storage";
|
|
3
|
+
import { AuthStorage, SqliteAuthCredentialStore } from "./auth-storage";
|
|
4
4
|
import { getOAuthProviders } from "./utils/oauth";
|
|
5
|
-
import type {
|
|
5
|
+
import type { OAuthProvider } from "./utils/oauth/types";
|
|
6
6
|
|
|
7
7
|
const PROVIDERS = getOAuthProviders();
|
|
8
8
|
|
|
@@ -58,289 +58,29 @@ function prompt(rl: readline.Interface, question: string): Promise<string> {
|
|
|
58
58
|
|
|
59
59
|
async function login(provider: OAuthProvider): Promise<void> {
|
|
60
60
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
-
|
|
62
61
|
const promptFn = (msg: string) => prompt(rl, `${msg} `);
|
|
63
|
-
const
|
|
62
|
+
const store = await SqliteAuthCredentialStore.open();
|
|
63
|
+
const storage = new AuthStorage(store);
|
|
64
|
+
await storage.reload();
|
|
64
65
|
|
|
65
66
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
case "github-copilot": {
|
|
84
|
-
const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot");
|
|
85
|
-
credentials = await loginGitHubCopilot({
|
|
86
|
-
onAuth(url, instructions) {
|
|
87
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
88
|
-
if (instructions) console.log(instructions);
|
|
89
|
-
console.log();
|
|
90
|
-
},
|
|
91
|
-
async onPrompt(p) {
|
|
92
|
-
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
case "google-gemini-cli": {
|
|
99
|
-
const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli");
|
|
100
|
-
credentials = await loginGeminiCli({
|
|
101
|
-
onAuth(info) {
|
|
102
|
-
const { url, instructions } = info;
|
|
103
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
104
|
-
if (instructions) console.log(instructions);
|
|
105
|
-
console.log();
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
case "google-antigravity": {
|
|
112
|
-
const { loginAntigravity } = await import("./utils/oauth/google-antigravity");
|
|
113
|
-
credentials = await loginAntigravity({
|
|
114
|
-
onAuth(info) {
|
|
115
|
-
const { url, instructions } = info;
|
|
116
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
117
|
-
if (instructions) console.log(instructions);
|
|
118
|
-
console.log();
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
case "openai-codex": {
|
|
124
|
-
const { loginOpenAICodex } = await import("./utils/oauth/openai-codex");
|
|
125
|
-
credentials = await loginOpenAICodex({
|
|
126
|
-
onAuth(info) {
|
|
127
|
-
const { url, instructions } = info;
|
|
128
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
129
|
-
if (instructions) console.log(instructions);
|
|
130
|
-
console.log();
|
|
131
|
-
},
|
|
132
|
-
async onPrompt(p) {
|
|
133
|
-
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
case "kimi-code": {
|
|
140
|
-
const { loginKimi } = await import("./utils/oauth/kimi");
|
|
141
|
-
credentials = await loginKimi({
|
|
142
|
-
onAuth(info) {
|
|
143
|
-
const { url, instructions } = info;
|
|
144
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
145
|
-
if (instructions) console.log(instructions);
|
|
146
|
-
console.log();
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
case "kilo": {
|
|
152
|
-
const { loginKilo } = await import("./utils/oauth/kilo");
|
|
153
|
-
credentials = await loginKilo({
|
|
154
|
-
onAuth(info) {
|
|
155
|
-
const { url, instructions } = info;
|
|
156
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
157
|
-
if (instructions) console.log(instructions);
|
|
158
|
-
console.log();
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
case "kagi": {
|
|
164
|
-
const { loginKagi } = await import("./utils/oauth/kagi");
|
|
165
|
-
const apiKey = await loginKagi({
|
|
166
|
-
onAuth(info) {
|
|
167
|
-
const { url, instructions } = info;
|
|
168
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
169
|
-
if (instructions) console.log(instructions);
|
|
170
|
-
console.log();
|
|
171
|
-
},
|
|
172
|
-
onPrompt(p) {
|
|
173
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
storage.saveApiKey(provider, apiKey);
|
|
177
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
case "tavily": {
|
|
181
|
-
const { loginTavily } = await import("./utils/oauth/tavily");
|
|
182
|
-
const apiKey = await loginTavily({
|
|
183
|
-
onAuth(info) {
|
|
184
|
-
const { url, instructions } = info;
|
|
185
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
186
|
-
if (instructions) console.log(instructions);
|
|
187
|
-
console.log();
|
|
188
|
-
},
|
|
189
|
-
onPrompt(p) {
|
|
190
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
storage.saveApiKey(provider, apiKey);
|
|
194
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
case "parallel": {
|
|
198
|
-
const { loginParallel } = await import("./utils/oauth/parallel");
|
|
199
|
-
const apiKey = await loginParallel({
|
|
200
|
-
onAuth(info) {
|
|
201
|
-
const { url, instructions } = info;
|
|
202
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
203
|
-
if (instructions) console.log(instructions);
|
|
204
|
-
console.log();
|
|
205
|
-
},
|
|
206
|
-
onPrompt(p) {
|
|
207
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
storage.saveApiKey(provider, apiKey);
|
|
211
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
case "cursor": {
|
|
216
|
-
const { loginCursor } = await import("./utils/oauth/cursor");
|
|
217
|
-
credentials = await loginCursor(
|
|
218
|
-
url => {
|
|
219
|
-
console.log(`\nOpen this URL in your browser:\n${url}\n`);
|
|
220
|
-
},
|
|
221
|
-
() => {
|
|
222
|
-
console.log("Waiting for browser authentication...");
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
case "zai": {
|
|
229
|
-
const { loginZai } = await import("./utils/oauth/zai");
|
|
230
|
-
const apiKey = await loginZai({
|
|
231
|
-
onAuth(info) {
|
|
232
|
-
const { url, instructions } = info;
|
|
233
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
234
|
-
if (instructions) console.log(instructions);
|
|
235
|
-
console.log();
|
|
236
|
-
},
|
|
237
|
-
onPrompt(p) {
|
|
238
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
storage.saveApiKey(provider, apiKey);
|
|
242
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
case "nanogpt": {
|
|
247
|
-
const { loginNanoGPT } = await import("./utils/oauth/nanogpt");
|
|
248
|
-
const apiKey = await loginNanoGPT({
|
|
249
|
-
onAuth(info) {
|
|
250
|
-
const { url, instructions } = info;
|
|
251
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
252
|
-
if (instructions) console.log(instructions);
|
|
253
|
-
console.log();
|
|
254
|
-
},
|
|
255
|
-
onPrompt(p) {
|
|
256
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
storage.saveApiKey(provider, apiKey);
|
|
260
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
case "zenmux": {
|
|
265
|
-
const { loginZenMux } = await import("./utils/oauth/zenmux");
|
|
266
|
-
const apiKey = await loginZenMux({
|
|
267
|
-
onAuth(info) {
|
|
268
|
-
const { url, instructions } = info;
|
|
269
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
270
|
-
if (instructions) console.log(instructions);
|
|
271
|
-
console.log();
|
|
272
|
-
},
|
|
273
|
-
onPrompt(p) {
|
|
274
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
storage.saveApiKey(provider, apiKey);
|
|
278
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
case "ollama-cloud": {
|
|
282
|
-
const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud");
|
|
283
|
-
const apiKey = await loginOllamaCloud({
|
|
284
|
-
onAuth(info) {
|
|
285
|
-
const { url, instructions } = info;
|
|
286
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
287
|
-
if (instructions) console.log(instructions);
|
|
288
|
-
console.log();
|
|
289
|
-
},
|
|
290
|
-
onPrompt(p) {
|
|
291
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
storage.saveApiKey(provider, apiKey);
|
|
295
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
case "minimax-code": {
|
|
300
|
-
const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code");
|
|
301
|
-
const apiKey = await loginMiniMaxCode({
|
|
302
|
-
onAuth(info) {
|
|
303
|
-
const { url, instructions } = info;
|
|
304
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
305
|
-
if (instructions) console.log(instructions);
|
|
306
|
-
console.log();
|
|
307
|
-
},
|
|
308
|
-
onPrompt(p) {
|
|
309
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
310
|
-
},
|
|
311
|
-
});
|
|
312
|
-
storage.saveApiKey(provider, apiKey);
|
|
313
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
case "minimax-code-cn": {
|
|
318
|
-
const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code");
|
|
319
|
-
const apiKey = await loginMiniMaxCodeCn({
|
|
320
|
-
onAuth(info) {
|
|
321
|
-
const { url, instructions } = info;
|
|
322
|
-
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
323
|
-
if (instructions) console.log(instructions);
|
|
324
|
-
console.log();
|
|
325
|
-
},
|
|
326
|
-
onPrompt(p) {
|
|
327
|
-
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
328
|
-
},
|
|
329
|
-
});
|
|
330
|
-
storage.saveApiKey(provider, apiKey);
|
|
331
|
-
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
default:
|
|
336
|
-
throw new Error(`Unknown provider: ${provider}`);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
storage.saveOAuth(provider, credentials);
|
|
340
|
-
|
|
67
|
+
await storage.login(provider, {
|
|
68
|
+
onAuth(info) {
|
|
69
|
+
const { url, instructions } = info;
|
|
70
|
+
console.log(`\nOpen this URL in your browser:\n${url}`);
|
|
71
|
+
if (instructions) console.log(instructions);
|
|
72
|
+
console.log();
|
|
73
|
+
},
|
|
74
|
+
onProgress(message) {
|
|
75
|
+
console.log(message);
|
|
76
|
+
},
|
|
77
|
+
onPrompt(p) {
|
|
78
|
+
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
341
81
|
console.log(`\nCredentials saved to ~/.omp/agent/agent.db`);
|
|
342
82
|
} finally {
|
|
343
|
-
|
|
83
|
+
store.close();
|
|
344
84
|
rl.close();
|
|
345
85
|
}
|
|
346
86
|
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { isValidJsonSchema } from "./meta-validator";
|
|
22
22
|
import { type DescriptionSpillFormat, spillToDescription } from "./spill";
|
|
23
23
|
import { enter, epochNext, exit, once, stamp } from "./stamps";
|
|
24
|
-
import { isJsonObject, type JsonObject } from "./types";
|
|
24
|
+
import { isJsonObject, isJsonObjectEmpty, type JsonObject } from "./types";
|
|
25
25
|
import { decontaminateZodInstance } from "./zod-decontaminate";
|
|
26
26
|
|
|
27
27
|
export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
|
|
@@ -907,6 +907,15 @@ export const normalizeSchemaForOpenAIResponses: (schema: JsonObject) => JsonObje
|
|
|
907
907
|
function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, JsonObject>): unknown {
|
|
908
908
|
if (!isJsonObject(value)) return value;
|
|
909
909
|
|
|
910
|
+
// `{}` (empty JSON Schema) ≡ `true` (JSON Schema draft 2020-12 §4.3.1).
|
|
911
|
+
// Grammar-constrained samplers (llama.cpp, etc.) treat the object form as
|
|
912
|
+
// "generate an empty object" rather than "any JSON value" (issue #1179).
|
|
913
|
+
// `toolWireSchema` already runs `normalizeEmptySchemas` upstream, but this
|
|
914
|
+
// guard remains as a safety net for callers that invoke
|
|
915
|
+
// `sanitizeSchemaForOpenAIResponses` directly on a schema that bypassed
|
|
916
|
+
// the wire-schema pipeline (e.g. provider-specific fixtures, debug paths).
|
|
917
|
+
if (isJsonObjectEmpty(value)) return true;
|
|
918
|
+
|
|
910
919
|
const cached = cache.get(value);
|
|
911
920
|
if (cached) return cached;
|
|
912
921
|
|
|
@@ -3,3 +3,9 @@ export type JsonObject = Record<string, unknown>;
|
|
|
3
3
|
export function isJsonObject(value: unknown): value is JsonObject {
|
|
4
4
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
/** True when `value` is a plain JSON object with no own enumerable keys. */
|
|
8
|
+
export function isJsonObjectEmpty(value: JsonObject): boolean {
|
|
9
|
+
for (const _ in value) return false;
|
|
10
|
+
return true;
|
|
11
|
+
}
|
package/src/utils/schema/wire.ts
CHANGED
|
@@ -62,16 +62,47 @@ const kJsonWireSchema = Symbol("pi.schema.json.wire");
|
|
|
62
62
|
* treat defaulted fields as optional; Zod inverts this and keeps them
|
|
63
63
|
* required at the input boundary, then materializes the default).
|
|
64
64
|
* - Strip the noisy safe-integer bounds Zod injects for `z.number().int()`.
|
|
65
|
+
*
|
|
66
|
+
* The empty-schema normalization (`{}` → `true`, see `normalizeEmptySchemas`)
|
|
67
|
+
* runs separately from `toolWireSchema` so both Zod and TypeBox tools get it.
|
|
65
68
|
*/
|
|
66
69
|
function postProcess(schema: Record<string, unknown>): Record<string, unknown> {
|
|
67
70
|
delete schema.$schema;
|
|
68
71
|
walk(schema);
|
|
72
|
+
normalizeEmptySchemas(schema);
|
|
69
73
|
return schema;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER;
|
|
73
77
|
const SAFE_INTEGER_MIN = Number.MIN_SAFE_INTEGER;
|
|
74
78
|
|
|
79
|
+
/** Keys whose values are a single JSON Schema (not an array or map). */
|
|
80
|
+
const SCHEMA_VALUE_KEYS = [
|
|
81
|
+
"additionalProperties",
|
|
82
|
+
"unevaluatedProperties",
|
|
83
|
+
"unevaluatedItems",
|
|
84
|
+
"items",
|
|
85
|
+
"contains",
|
|
86
|
+
"propertyNames",
|
|
87
|
+
"if",
|
|
88
|
+
"then",
|
|
89
|
+
"else",
|
|
90
|
+
"not",
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
/** Keys whose values are a map of `{ key: Schema }` entries. */
|
|
94
|
+
const SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions"] as const;
|
|
95
|
+
|
|
96
|
+
/** Keys whose values are an array of schemas. */
|
|
97
|
+
const SCHEMA_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"] as const;
|
|
98
|
+
|
|
99
|
+
/** True when `val` is a plain empty object `{}`. */
|
|
100
|
+
function isEmptyObject(val: unknown): val is Record<string, never> {
|
|
101
|
+
if (val === null || typeof val !== "object" || Array.isArray(val)) return false;
|
|
102
|
+
for (const _ in val as object) return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
75
106
|
function walk(node: unknown): void {
|
|
76
107
|
if (Array.isArray(node)) {
|
|
77
108
|
for (const child of node) walk(child);
|
|
@@ -107,6 +138,50 @@ function walk(node: unknown): void {
|
|
|
107
138
|
for (const k in obj) walk(obj[k]);
|
|
108
139
|
}
|
|
109
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Normalize `{}` (empty JSON Schema = `z.unknown()` / unconstrained value) to
|
|
143
|
+
* boolean `true` in every schema-valued position. JSON Schema draft 2020-12
|
|
144
|
+
* §4.3.1: `{}` and `true` are semantically equivalent ("any JSON value").
|
|
145
|
+
* Grammar-constrained samplers (llama.cpp, etc.) treat the object form as
|
|
146
|
+
* "generate an empty object" rather than "any JSON value", causing open-typed
|
|
147
|
+
* fields like `extra.title` (from `z.record(z.string(), z.unknown())`) to
|
|
148
|
+
* always emit `{}` instead of the intended string/number/etc. (issue #1179).
|
|
149
|
+
*
|
|
150
|
+
* Mutates in place. Provider-agnostic — applied to every tool wire schema so
|
|
151
|
+
* Anthropic, Google, OpenAI, Ollama, Bedrock, and Cursor all see the
|
|
152
|
+
* normalized form, regardless of whether the source was Zod or TypeBox.
|
|
153
|
+
*/
|
|
154
|
+
export function normalizeEmptySchemas(node: unknown): void {
|
|
155
|
+
if (Array.isArray(node)) {
|
|
156
|
+
for (const child of node) normalizeEmptySchemas(child);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!node || typeof node !== "object") return;
|
|
160
|
+
const obj = node as Record<string, unknown>;
|
|
161
|
+
|
|
162
|
+
for (const key of SCHEMA_VALUE_KEYS) {
|
|
163
|
+
if (Object.hasOwn(obj, key) && isEmptyObject(obj[key])) obj[key] = true;
|
|
164
|
+
}
|
|
165
|
+
for (const mapKey of SCHEMA_MAP_KEYS) {
|
|
166
|
+
const map = obj[mapKey];
|
|
167
|
+
if (map !== null && typeof map === "object" && !Array.isArray(map)) {
|
|
168
|
+
for (const k in map as Record<string, unknown>) {
|
|
169
|
+
if (isEmptyObject((map as Record<string, unknown>)[k])) (map as Record<string, unknown>)[k] = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const arrKey of SCHEMA_ARRAY_KEYS) {
|
|
174
|
+
const arr = obj[arrKey];
|
|
175
|
+
if (Array.isArray(arr)) {
|
|
176
|
+
for (let i = 0; i < arr.length; i++) {
|
|
177
|
+
if (isEmptyObject(arr[i])) arr[i] = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const k in obj) normalizeEmptySchemas(obj[k]);
|
|
183
|
+
}
|
|
184
|
+
|
|
110
185
|
/** Convert a Zod schema into the JSON Schema shape providers consume. */
|
|
111
186
|
export function zodToWireSchema(schema: ZodType): Record<string, unknown> {
|
|
112
187
|
return stamp(schema, kZodWireSchema, s => {
|
|
@@ -122,13 +197,17 @@ export function zodToWireSchema(schema: ZodType): Record<string, unknown> {
|
|
|
122
197
|
* Resolve a tool's parameters to a JSON Schema object suitable for sending
|
|
123
198
|
* over the wire. Zod schemas are converted (and cached); legacy TypeBox / raw
|
|
124
199
|
* JSON Schema parameters are upgraded to draft 2020-12 (and cached).
|
|
200
|
+
*
|
|
201
|
+
* Both branches finish with `normalizeEmptySchemas` so every provider —
|
|
202
|
+
* OpenAI, Anthropic, Google, Ollama, Bedrock, Cursor — sees `{}` normalized
|
|
203
|
+
* to `true` in schema-valued positions (issue #1179).
|
|
125
204
|
*/
|
|
126
205
|
export function toolWireSchema(tool: Tool): Record<string, unknown> {
|
|
127
206
|
const params: TSchema = tool.parameters;
|
|
128
207
|
if (isZodSchema(params)) return zodToWireSchema(params);
|
|
129
|
-
return stamp(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
);
|
|
208
|
+
return stamp(params as Record<string, unknown>, kJsonWireSchema, p => {
|
|
209
|
+
const upgraded = upgradeJsonSchemaTo202012(p) as Record<string, unknown>;
|
|
210
|
+
normalizeEmptySchemas(upgraded);
|
|
211
|
+
return upgraded;
|
|
212
|
+
});
|
|
134
213
|
}
|