@oh-my-pi/pi-ai 16.3.4 → 16.3.5
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 +11 -0
- package/dist/types/auth-storage.d.ts +2 -5
- package/dist/types/registry/oauth/types.d.ts +14 -0
- package/package.json +4 -4
- package/src/auth-storage.ts +8 -2
- package/src/providers/openai-codex-responses.ts +6 -1
- package/src/providers/openai-completions.ts +12 -1
- package/src/providers/openai-responses.ts +10 -1
- package/src/registry/oauth/callback-server.ts +97 -9
- package/src/registry/oauth/types.ts +14 -0
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/validation.ts +295 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.5] - 2026-07-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `OAuthCallbackFlow` now serves a `GET /launch` route on its loopback callback server that 302-redirects to the pending authorization URL, and exposes that short URL as `OAuthAuthInfo.launchUrl`. UIs can advertise it as a truncation-safe copy target (~30 chars) instead of the full authorize URL, so terminals narrower than the composed row cannot silently drop OAuth query parameters like `code_challenge_method=S256` ([#4418](https://github.com/can1357/oh-my-pi/issues/4418)).
|
|
10
|
+
- Preserved explicit `tool.strict === false` on OpenAI-family function tool payloads (openai-responses, openai-codex-responses, openai-completions) so backends that distinguish `strict: false` from an omitted flag stop over-filling optional arguments ([#4336](https://github.com/can1357/oh-my-pi/issues/4336)).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed tool-call validation to strip stray trailing line terminators on schema-matching enum values and on well-known identifier fields (`path`, `paths`, `file`, `file_path`, `url`, `uri`, `title`, `label`) before dispatch, keeping ordinary trailing spaces and content-carrying fields (`content`, `input`, `code`, `command`, etc.) intact ([#4461](https://github.com/can1357/oh-my-pi/issues/4461)).
|
|
15
|
+
|
|
5
16
|
## [16.3.4] - 2026-07-03
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { Database } from "bun:sqlite";
|
|
11
11
|
import type { ApiKeyResolver } from "./auth-retry";
|
|
12
|
-
import type { OAuthController, OAuthCredentials, OAuthProviderId } from "./registry/oauth/types";
|
|
12
|
+
import type { OAuthAuthInfo, OAuthController, OAuthCredentials, OAuthProviderId } from "./registry/oauth/types";
|
|
13
13
|
import type { Provider } from "./types";
|
|
14
14
|
import type { CredentialRankingStrategy, UsageCostHistoryEntry, UsageCostHistoryQuery, UsageHistoryEntry, UsageHistoryQuery, UsageLogger, UsageProvider, UsageReport } from "./usage";
|
|
15
15
|
import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/openai-codex-reset";
|
|
@@ -694,10 +694,7 @@ export declare class AuthStorage {
|
|
|
694
694
|
*/
|
|
695
695
|
login(provider: OAuthProviderId, ctrl: OAuthController & {
|
|
696
696
|
/** onAuth is required by auth-storage but optional in OAuthController */
|
|
697
|
-
onAuth: (info:
|
|
698
|
-
url: string;
|
|
699
|
-
instructions?: string;
|
|
700
|
-
}) => void;
|
|
697
|
+
onAuth: (info: OAuthAuthInfo) => void;
|
|
701
698
|
/** onPrompt is required for some providers (github-copilot, openai-codex) */
|
|
702
699
|
onPrompt: (prompt: {
|
|
703
700
|
message: string;
|
|
@@ -18,7 +18,21 @@ export type OAuthPrompt = {
|
|
|
18
18
|
allowEmpty?: boolean;
|
|
19
19
|
};
|
|
20
20
|
export type OAuthAuthInfo = {
|
|
21
|
+
/**
|
|
22
|
+
* Full authorization URL. Suitable for direct browser launch, OSC 8
|
|
23
|
+
* hyperlinks, and clipboard when the target UI can guarantee the full
|
|
24
|
+
* string reaches the user unmodified.
|
|
25
|
+
*/
|
|
21
26
|
url: string;
|
|
27
|
+
/**
|
|
28
|
+
* Short loopback URL that 302-redirects to {@link url}. Provided by flows
|
|
29
|
+
* that host the redirect on the same callback server they already run
|
|
30
|
+
* ({@link OAuthCallbackFlow}). UIs SHOULD prefer this as the copy target
|
|
31
|
+
* so viewport truncation cannot corrupt OAuth query parameters. Undefined
|
|
32
|
+
* for flows without a loopback callback server (device code, paste-code
|
|
33
|
+
* providers with fixed non-loopback redirects, etc.).
|
|
34
|
+
*/
|
|
35
|
+
launchUrl?: string;
|
|
22
36
|
instructions?: string;
|
|
23
37
|
};
|
|
24
38
|
export interface OAuthProviderInfo {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.5",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.3.5",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.3.5",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.3.5",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -16,7 +16,13 @@ import * as AIError from "./error";
|
|
|
16
16
|
import { isUsageLimitOutcome } from "./error/rate-limit";
|
|
17
17
|
import { getProviderDefinition, PASTE_CODE_LOGIN_PROVIDERS } from "./registry";
|
|
18
18
|
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
OAuthAuthInfo,
|
|
21
|
+
OAuthController,
|
|
22
|
+
OAuthCredentials,
|
|
23
|
+
OAuthProvider,
|
|
24
|
+
OAuthProviderId,
|
|
25
|
+
} from "./registry/oauth/types";
|
|
20
26
|
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
|
21
27
|
import type { Provider } from "./types";
|
|
22
28
|
import type {
|
|
@@ -1863,7 +1869,7 @@ export class AuthStorage {
|
|
|
1863
1869
|
provider: OAuthProviderId,
|
|
1864
1870
|
ctrl: OAuthController & {
|
|
1865
1871
|
/** onAuth is required by auth-storage but optional in OAuthController */
|
|
1866
|
-
onAuth: (info:
|
|
1872
|
+
onAuth: (info: OAuthAuthInfo) => void;
|
|
1867
1873
|
/** onPrompt is required for some providers (github-copilot, openai-codex) */
|
|
1868
1874
|
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
|
1869
1875
|
},
|
|
@@ -3311,7 +3311,12 @@ export function convertOpenAICodexResponsesTools(
|
|
|
3311
3311
|
name: tool.name,
|
|
3312
3312
|
description: tool.description || "",
|
|
3313
3313
|
parameters,
|
|
3314
|
-
|
|
3314
|
+
// See openai-responses.ts::convertTools — explicit `strict: false` is
|
|
3315
|
+
// preserved on the wire because some backends distinguish it from
|
|
3316
|
+
// omitted (#4336). `strict: true` still requires enforcement success,
|
|
3317
|
+
// and the `PI_NO_STRICT` global bypass MUST suppress the flag entirely
|
|
3318
|
+
// so Codex proxies that reject the `strict` key stay silent.
|
|
3319
|
+
...(effectiveStrict ? { strict: true } : !NO_STRICT && tool.strict === false ? { strict: false } : {}),
|
|
3315
3320
|
};
|
|
3316
3321
|
});
|
|
3317
3322
|
}
|
|
@@ -2115,6 +2115,17 @@ function convertTools(
|
|
|
2115
2115
|
return {
|
|
2116
2116
|
tools: adaptedTools.map(({ tool, baseParameters, parameters, strict }) => {
|
|
2117
2117
|
const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict);
|
|
2118
|
+
// `strict: false` is semantically distinct from omitted `strict` on some
|
|
2119
|
+
// backends: with it absent, optional properties may be over-filled with
|
|
2120
|
+
// placeholder values (#4336). Preserve the author's explicit `false`,
|
|
2121
|
+
// but only in "mixed" mode against a provider that understands the
|
|
2122
|
+
// field — the `all_strict → none` collapse and `supportsStrictMode:
|
|
2123
|
+
// false` paths deliberately keep the wire flag uniformly absent.
|
|
2124
|
+
const includeExplicitFalse =
|
|
2125
|
+
!includeStrict &&
|
|
2126
|
+
tool.strict === false &&
|
|
2127
|
+
toolStrictMode === "mixed" &&
|
|
2128
|
+
compat.supportsStrictMode !== false;
|
|
2118
2129
|
const wireParameters = includeStrict ? parameters : baseParameters;
|
|
2119
2130
|
return {
|
|
2120
2131
|
type: "function",
|
|
@@ -2128,7 +2139,7 @@ function convertTools(
|
|
|
2128
2139
|
? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
|
|
2129
2140
|
: wireParameters,
|
|
2130
2141
|
// Only include strict if provider supports it. Some reject unknown fields.
|
|
2131
|
-
...(includeStrict
|
|
2142
|
+
...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
|
|
2132
2143
|
},
|
|
2133
2144
|
};
|
|
2134
2145
|
}),
|
|
@@ -987,7 +987,16 @@ export function convertTools(
|
|
|
987
987
|
name: tool.name,
|
|
988
988
|
description: tool.description || "",
|
|
989
989
|
parameters,
|
|
990
|
-
|
|
990
|
+
// `strict: false` and an omitted `strict` are NOT equivalent for every
|
|
991
|
+
// OpenAI-compat backend — some over-fill optional args when the flag is
|
|
992
|
+
// absent (#4336). Preserve the author's explicit `false` only while the
|
|
993
|
+
// Responses strict field is enabled; compatibility disables and
|
|
994
|
+
// strict-schema fallback retries rely on uniformly absent flags.
|
|
995
|
+
...(effectiveStrict
|
|
996
|
+
? { strict: true }
|
|
997
|
+
: !NO_STRICT && strictMode && tool.strict === false
|
|
998
|
+
? { strict: false }
|
|
999
|
+
: {}),
|
|
991
1000
|
} as OpenAITool);
|
|
992
1001
|
}
|
|
993
1002
|
return out;
|
|
@@ -17,6 +17,14 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
17
17
|
const DEFAULT_TIMEOUT = 300_000;
|
|
18
18
|
const DEFAULT_HOSTNAME = "localhost";
|
|
19
19
|
const CALLBACK_PATH = "/callback";
|
|
20
|
+
/**
|
|
21
|
+
* Path served by {@link OAuthCallbackFlow} that 302-redirects to the pending
|
|
22
|
+
* authorization URL. Kept out of {@link OAuthCallbackFlowOptions} because it
|
|
23
|
+
* lives on the loopback callback server alongside {@link CALLBACK_PATH} and
|
|
24
|
+
* must never clash with a provider-registered redirect URI (all known
|
|
25
|
+
* providers register `/callback`-shaped paths).
|
|
26
|
+
*/
|
|
27
|
+
const LAUNCH_PATH = "/launch";
|
|
20
28
|
|
|
21
29
|
export type CallbackResult = { code: string; state: string };
|
|
22
30
|
|
|
@@ -54,6 +62,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
54
62
|
allowPortFallback: boolean;
|
|
55
63
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
56
64
|
#callbackReject?: (error: string) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Authorization URL the `/launch` route currently redirects to. Set by
|
|
67
|
+
* {@link login} after {@link generateAuthUrl} and before {@link OAuthController.onAuth}
|
|
68
|
+
* fires, cleared when the server stops. `undefined` before the flow reaches
|
|
69
|
+
* that point and after it finishes, so `/launch` returns 503 rather than
|
|
70
|
+
* a stale URL.
|
|
71
|
+
*/
|
|
72
|
+
#pendingAuthUrl?: string;
|
|
57
73
|
|
|
58
74
|
constructor(
|
|
59
75
|
ctrl: OAuthController,
|
|
@@ -120,7 +136,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
120
136
|
this.#throwIfCancelled();
|
|
121
137
|
|
|
122
138
|
// Start callback server first to get actual redirect URI
|
|
123
|
-
const { server, redirectUri } = await this.#startCallbackServer(state);
|
|
139
|
+
const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
|
|
124
140
|
|
|
125
141
|
try {
|
|
126
142
|
this.#throwIfCancelled();
|
|
@@ -128,8 +144,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
128
144
|
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
129
145
|
this.#throwIfCancelled();
|
|
130
146
|
|
|
147
|
+
// Publish the auth URL to the `/launch` route BEFORE handing it to
|
|
148
|
+
// callers. `onAuth` immediately renders a UI that advertises the
|
|
149
|
+
// launch URL as a copy target, so `/launch` must already resolve if
|
|
150
|
+
// the user clicks/pastes it during the same render pass.
|
|
151
|
+
this.#pendingAuthUrl = authUrl;
|
|
152
|
+
|
|
131
153
|
// Notify controller that auth is ready
|
|
132
|
-
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
154
|
+
this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
|
|
133
155
|
this.ctrl.onProgress?.("Waiting for browser authentication...");
|
|
134
156
|
|
|
135
157
|
// Wait for callback or manual input
|
|
@@ -140,21 +162,33 @@ export abstract class OAuthCallbackFlow {
|
|
|
140
162
|
|
|
141
163
|
return await this.exchangeToken(code, state, redirectUri);
|
|
142
164
|
} finally {
|
|
165
|
+
this.#pendingAuthUrl = undefined;
|
|
143
166
|
server.stop();
|
|
144
167
|
}
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
/**
|
|
148
171
|
* Start callback server, trying preferred port first, falling back to random.
|
|
172
|
+
* `launchUrl` is `undefined` when the caller configured `callbackPath` to
|
|
173
|
+
* collide with {@link LAUNCH_PATH} — the callback handler resolves the real
|
|
174
|
+
* callback in that case, so advertising a self-redirecting URL would be
|
|
175
|
+
* incorrect.
|
|
149
176
|
*/
|
|
150
|
-
async #startCallbackServer(
|
|
177
|
+
async #startCallbackServer(
|
|
178
|
+
expectedState: string,
|
|
179
|
+
): Promise<{ server: Bun.Server<unknown>; redirectUri: string; launchUrl: string | undefined }> {
|
|
151
180
|
try {
|
|
152
181
|
const server = this.#createServer(this.preferredPort, expectedState);
|
|
182
|
+
// `preferredPort: 0` opts into a random port — read the actual bound
|
|
183
|
+
// port from the server so both the redirect URI and launch URL point at
|
|
184
|
+
// a reachable socket, not the sentinel.
|
|
185
|
+
const actualPort = this.#resolveServerPort(server);
|
|
186
|
+
const launchUrl = this.#launchUrlIfSafe(actualPort);
|
|
153
187
|
if (this.redirectUri) {
|
|
154
|
-
return { server, redirectUri: this.redirectUri };
|
|
188
|
+
return { server, redirectUri: this.redirectUri, launchUrl };
|
|
155
189
|
}
|
|
156
|
-
const redirectUri = `http://${this.callbackHostname}:${
|
|
157
|
-
return { server, redirectUri };
|
|
190
|
+
const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
|
|
191
|
+
return { server, redirectUri, launchUrl };
|
|
158
192
|
} catch (cause) {
|
|
159
193
|
if (this.redirectUri) {
|
|
160
194
|
throw new AIError.ConfigurationError(
|
|
@@ -169,11 +203,49 @@ export abstract class OAuthCallbackFlow {
|
|
|
169
203
|
);
|
|
170
204
|
}
|
|
171
205
|
const server = this.#createServer(0, expectedState);
|
|
172
|
-
const actualPort = server
|
|
206
|
+
const actualPort = this.#resolveServerPort(server);
|
|
173
207
|
const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
|
|
208
|
+
const launchUrl = this.#launchUrlIfSafe(actualPort);
|
|
174
209
|
this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
|
|
175
|
-
return { server, redirectUri };
|
|
210
|
+
return { server, redirectUri, launchUrl };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read the numeric port a callback server bound to. `Bun.Server.port` is
|
|
216
|
+
* declared `number | undefined` because Unix-socket servers have no port,
|
|
217
|
+
* but every callback flow uses TCP; a missing port here indicates a
|
|
218
|
+
* configuration error rather than a fallback case.
|
|
219
|
+
*/
|
|
220
|
+
#resolveServerPort(server: Bun.Server<unknown>): number {
|
|
221
|
+
const port = server.port;
|
|
222
|
+
if (typeof port !== "number") {
|
|
223
|
+
throw new AIError.ConfigurationError(
|
|
224
|
+
"OAuth callback server bound to a non-TCP endpoint; expected a numeric port. Check `oauth.callbackPort`/`oauth.redirectUri`.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return port;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the `/launch` URL served by the callback server bound to `port`, or
|
|
232
|
+
* `undefined` when the configured `callbackPath` (or a `redirectUri` whose
|
|
233
|
+
* pathname resolves to {@link LAUNCH_PATH}) would collide with the launch
|
|
234
|
+
* route. Kept short (~30 chars) so UIs can advertise it as a
|
|
235
|
+
* viewport-truncation-safe copy target for the full authorization URL.
|
|
236
|
+
*/
|
|
237
|
+
#launchUrlIfSafe(port: number): string | undefined {
|
|
238
|
+
if (this.callbackPath === LAUNCH_PATH) return undefined;
|
|
239
|
+
if (this.redirectUri) {
|
|
240
|
+
try {
|
|
241
|
+
if (new URL(this.redirectUri).pathname === LAUNCH_PATH) return undefined;
|
|
242
|
+
} catch {
|
|
243
|
+
// A non-parseable redirectUri (e.g. `vscode://...` handled elsewhere)
|
|
244
|
+
// can't collide with an HTTP `/launch` route — fall through and
|
|
245
|
+
// advertise the launch URL against the loopback server.
|
|
246
|
+
}
|
|
176
247
|
}
|
|
248
|
+
return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
|
|
177
249
|
}
|
|
178
250
|
|
|
179
251
|
/**
|
|
@@ -190,12 +262,28 @@ export abstract class OAuthCallbackFlow {
|
|
|
190
262
|
}
|
|
191
263
|
|
|
192
264
|
/**
|
|
193
|
-
* Handle OAuth callback HTTP request.
|
|
265
|
+
* Handle OAuth callback HTTP request. Two routes on the same loopback server:
|
|
266
|
+
* - `callbackPath` (default `/callback`) — the provider redirect target.
|
|
267
|
+
* - {@link LAUNCH_PATH} (`/launch`) — 302 to the pending authorization URL so
|
|
268
|
+
* viewport-safe copy targets can survive TUI truncation.
|
|
269
|
+
*
|
|
270
|
+
* `callbackPath` wins any collision: an OMP config that pins the provider
|
|
271
|
+
* redirect at `/launch` (via `oauth.callbackPath` or a loopback
|
|
272
|
+
* `oauth.redirectUri`) must resolve the callback normally rather than
|
|
273
|
+
* self-redirect. `#startCallbackServer` also suppresses `launchUrl` in that
|
|
274
|
+
* case, so the launch route is never advertised when it would collide.
|
|
194
275
|
*/
|
|
195
276
|
#handleCallback(req: Request, expectedState: string): Response {
|
|
196
277
|
const url = new URL(req.url);
|
|
197
278
|
|
|
198
279
|
if (url.pathname !== this.callbackPath) {
|
|
280
|
+
if (url.pathname === LAUNCH_PATH) {
|
|
281
|
+
const pending = this.#pendingAuthUrl;
|
|
282
|
+
if (!pending) {
|
|
283
|
+
return new Response("OAuth launch URL is no longer active", { status: 503 });
|
|
284
|
+
}
|
|
285
|
+
return Response.redirect(pending, 302);
|
|
286
|
+
}
|
|
199
287
|
return new Response("Not Found", { status: 404 });
|
|
200
288
|
}
|
|
201
289
|
|
|
@@ -23,7 +23,21 @@ export type OAuthPrompt = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
export type OAuthAuthInfo = {
|
|
26
|
+
/**
|
|
27
|
+
* Full authorization URL. Suitable for direct browser launch, OSC 8
|
|
28
|
+
* hyperlinks, and clipboard when the target UI can guarantee the full
|
|
29
|
+
* string reaches the user unmodified.
|
|
30
|
+
*/
|
|
26
31
|
url: string;
|
|
32
|
+
/**
|
|
33
|
+
* Short loopback URL that 302-redirects to {@link url}. Provided by flows
|
|
34
|
+
* that host the redirect on the same callback server they already run
|
|
35
|
+
* ({@link OAuthCallbackFlow}). UIs SHOULD prefer this as the copy target
|
|
36
|
+
* so viewport truncation cannot corrupt OAuth query parameters. Undefined
|
|
37
|
+
* for flows without a loopback callback server (device code, paste-code
|
|
38
|
+
* providers with fixed non-loopback redirects, etc.).
|
|
39
|
+
*/
|
|
40
|
+
launchUrl?: string;
|
|
27
41
|
instructions?: string;
|
|
28
42
|
};
|
|
29
43
|
|
|
@@ -53,6 +53,7 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
|
|
|
53
53
|
|
|
54
54
|
6. **Provider payload strict flag must match effective strictness**
|
|
55
55
|
- Callers MUST send `strict: true` only if enforcement succeeded (`effectiveStrict === true`).
|
|
56
|
+
- Callers MUST preserve an author's explicit `tool.strict === false` on the wire so that `strict: false` and omitted `strict` remain distinguishable — some OpenAI-compat backends over-fill optional fields when the flag is absent but respect it when set to `false` (#4336). Exceptions: `openai-responses` emits explicit `false` only while its `strictMode` gate and `PI_NO_STRICT` permit sending the strict field; `openai-codex-responses` gates explicit `false` on `!PI_NO_STRICT` so the documented global bypass keeps the `strict` key off the wire for Codex proxies that reject it; `openai-completions` emits explicit `false` only in `toolStrictMode === "mixed"` with `compat.supportsStrictMode !== false`, because the `all_strict → none` collapse and providers that reject the `strict` key rely on uniform absence.
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
package/src/utils/validation.ts
CHANGED
|
@@ -835,6 +835,263 @@ function normalizeOptionalNullsForSchema(
|
|
|
835
835
|
return { value: changed ? nextValue : value, changed };
|
|
836
836
|
}
|
|
837
837
|
|
|
838
|
+
function decodeJsonPointerToken(token: string): string {
|
|
839
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function resolveLocalJsonSchemaRef(root: unknown, ref: string): unknown | undefined {
|
|
843
|
+
if (ref === "#") return root;
|
|
844
|
+
if (!ref.startsWith("#/")) return undefined;
|
|
845
|
+
let current: unknown = root;
|
|
846
|
+
for (const rawToken of ref.slice(2).split("/")) {
|
|
847
|
+
const token = decodeJsonPointerToken(rawToken);
|
|
848
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
849
|
+
current = (current as Record<string, unknown>)[token];
|
|
850
|
+
}
|
|
851
|
+
return current;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function normalizeEnumStringWhitespace(
|
|
855
|
+
schema: unknown,
|
|
856
|
+
value: unknown,
|
|
857
|
+
root: unknown = schema,
|
|
858
|
+
refs: ReadonlySet<string> = new Set(),
|
|
859
|
+
): { value: unknown; changed: boolean } {
|
|
860
|
+
if (value === null || value === undefined) return { value, changed: false };
|
|
861
|
+
if (schema === null || typeof schema !== "object") return { value, changed: false };
|
|
862
|
+
|
|
863
|
+
const schemaObject = schema as Record<string, unknown>;
|
|
864
|
+
const ref = schemaObject.$ref;
|
|
865
|
+
if (typeof ref === "string") {
|
|
866
|
+
if (refs.has(ref)) return { value, changed: false };
|
|
867
|
+
const resolved = resolveLocalJsonSchemaRef(root, ref);
|
|
868
|
+
if (resolved === undefined) return { value, changed: false };
|
|
869
|
+
return normalizeEnumStringWhitespace(resolved, value, root, new Set([...refs, ref]));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const branchMatches = (branch: unknown, candidate: unknown): boolean => {
|
|
873
|
+
if (branch !== null && typeof branch === "object") {
|
|
874
|
+
const branchRef = (branch as Record<string, unknown>).$ref;
|
|
875
|
+
if (typeof branchRef === "string" && !refs.has(branchRef)) {
|
|
876
|
+
const resolved = resolveLocalJsonSchemaRef(root, branchRef);
|
|
877
|
+
if (resolved !== undefined) return branchMatchesSchema(resolved, candidate);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return branchMatchesSchema(branch, candidate);
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const normalizeAnyOfLike = (keyword: "anyOf" | "oneOf"): { value: unknown; changed: boolean } => {
|
|
884
|
+
const branches = schemaObject[keyword];
|
|
885
|
+
if (!Array.isArray(branches)) return { value, changed: false };
|
|
886
|
+
if (branches.some(branch => branchMatches(branch, value))) return { value, changed: false };
|
|
887
|
+
|
|
888
|
+
for (const branch of branches) {
|
|
889
|
+
const normalized = normalizeEnumStringWhitespace(branch, value, root, refs);
|
|
890
|
+
if (!normalized.changed) continue;
|
|
891
|
+
if (branchMatches(branch, normalized.value)) return normalized;
|
|
892
|
+
}
|
|
893
|
+
return { value, changed: false };
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const anyOfNormalization = normalizeAnyOfLike("anyOf");
|
|
897
|
+
if (anyOfNormalization.changed) return anyOfNormalization;
|
|
898
|
+
|
|
899
|
+
const oneOfNormalization = normalizeAnyOfLike("oneOf");
|
|
900
|
+
if (oneOfNormalization.changed) return oneOfNormalization;
|
|
901
|
+
|
|
902
|
+
if (Array.isArray(schemaObject.allOf)) {
|
|
903
|
+
let changed = false;
|
|
904
|
+
let nextValue: unknown = value;
|
|
905
|
+
for (const branch of schemaObject.allOf) {
|
|
906
|
+
const normalized = normalizeEnumStringWhitespace(branch, nextValue, root, refs);
|
|
907
|
+
if (!normalized.changed) continue;
|
|
908
|
+
nextValue = normalized.value;
|
|
909
|
+
changed = true;
|
|
910
|
+
}
|
|
911
|
+
if (changed) return { value: nextValue, changed: true };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (typeof value === "string") {
|
|
915
|
+
const trimmed = value.trim();
|
|
916
|
+
if (trimmed !== value) {
|
|
917
|
+
const enumValues = schemaObject.enum;
|
|
918
|
+
if (Array.isArray(enumValues) && !enumValues.includes(value) && enumValues.includes(trimmed)) {
|
|
919
|
+
return { value: trimmed, changed: true };
|
|
920
|
+
}
|
|
921
|
+
const constValue = schemaObject.const;
|
|
922
|
+
if (typeof constValue === "string" && trimmed === constValue) {
|
|
923
|
+
return { value: trimmed, changed: true };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return { value, changed: false };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (Array.isArray(value)) {
|
|
930
|
+
let changed = false;
|
|
931
|
+
let nextValue = value;
|
|
932
|
+
const prefixItems = schemaObject.prefixItems;
|
|
933
|
+
if (Array.isArray(prefixItems)) {
|
|
934
|
+
for (let i = 0; i < value.length && i < prefixItems.length; i += 1) {
|
|
935
|
+
const itemSchema = prefixItems[i];
|
|
936
|
+
const normalized = normalizeEnumStringWhitespace(itemSchema, value[i], root, refs);
|
|
937
|
+
if (!normalized.changed) continue;
|
|
938
|
+
if (!changed) {
|
|
939
|
+
nextValue = [...value];
|
|
940
|
+
changed = true;
|
|
941
|
+
}
|
|
942
|
+
nextValue[i] = normalized.value;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const itemSchema = schemaObject.items;
|
|
947
|
+
if (itemSchema !== null && typeof itemSchema === "object" && !Array.isArray(itemSchema)) {
|
|
948
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
949
|
+
if (Array.isArray(prefixItems) && i < prefixItems.length) continue;
|
|
950
|
+
const normalized = normalizeEnumStringWhitespace(itemSchema, nextValue[i], root, refs);
|
|
951
|
+
if (!normalized.changed) continue;
|
|
952
|
+
if (!changed) {
|
|
953
|
+
nextValue = [...value];
|
|
954
|
+
changed = true;
|
|
955
|
+
}
|
|
956
|
+
nextValue[i] = normalized.value;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return { value: changed ? nextValue : value, changed };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (typeof value !== "object") return { value, changed: false };
|
|
963
|
+
const properties = schemaObject.properties;
|
|
964
|
+
if (!properties || typeof properties !== "object") return { value, changed: false };
|
|
965
|
+
|
|
966
|
+
const propsObject = properties as Record<string, unknown>;
|
|
967
|
+
const valueObject = value as Record<string, unknown>;
|
|
968
|
+
let changed = false;
|
|
969
|
+
let nextValue = valueObject;
|
|
970
|
+
for (const [key, propertySchema] of Object.entries(propsObject)) {
|
|
971
|
+
if (!(key in nextValue)) continue;
|
|
972
|
+
const normalized = normalizeEnumStringWhitespace(propertySchema, nextValue[key], root, refs);
|
|
973
|
+
if (!normalized.changed) continue;
|
|
974
|
+
if (!changed) {
|
|
975
|
+
nextValue = { ...nextValue };
|
|
976
|
+
changed = true;
|
|
977
|
+
}
|
|
978
|
+
nextValue[key] = normalized.value;
|
|
979
|
+
}
|
|
980
|
+
return { value: changed ? nextValue : valueObject, changed };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ============================================================================
|
|
984
|
+
// Identifier-string trailing-whitespace normalization (LLM quirk).
|
|
985
|
+
// ============================================================================
|
|
986
|
+
//
|
|
987
|
+
// LLMs sometimes emit tool arguments with a trailing newline dangling off a
|
|
988
|
+
// short identifier — a path, URL, or a display label like `title`. These
|
|
989
|
+
// values are never legitimately terminated by line breaks, so we strip trailing
|
|
990
|
+
// line terminators from string values on the well-known keys below before the
|
|
991
|
+
// tool ever sees them. Content-carrying properties (`content`, `input`, `body`,
|
|
992
|
+
// `text`, `command`, `code`) are intentionally not traversed or trimmed so
|
|
993
|
+
// genuine trailing whitespace survives on writes, patches, shell commands, and
|
|
994
|
+
// eval snippets.
|
|
995
|
+
// ============================================================================
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Property names whose values are treated as short identifiers — filesystem
|
|
999
|
+
* paths, URLs, URIs, or display labels. The trim only fires on strings sitting
|
|
1000
|
+
* under one of these keys, so `path: "docs/report "` still targets the file
|
|
1001
|
+
* whose name ends in a space.
|
|
1002
|
+
*/
|
|
1003
|
+
const IDENTIFIER_STRING_KEYS: ReadonlySet<string> = new Set([
|
|
1004
|
+
"path",
|
|
1005
|
+
"paths",
|
|
1006
|
+
"file",
|
|
1007
|
+
"file_path",
|
|
1008
|
+
"filePath",
|
|
1009
|
+
"filepath",
|
|
1010
|
+
"url",
|
|
1011
|
+
"uri",
|
|
1012
|
+
"title",
|
|
1013
|
+
"label",
|
|
1014
|
+
]);
|
|
1015
|
+
|
|
1016
|
+
const CONTENT_CARRYING_KEYS: ReadonlySet<string> = new Set(["content", "input", "body", "text", "command", "code"]);
|
|
1017
|
+
|
|
1018
|
+
const TRAILING_LINE_TERMINATOR_RE = /[\r\n]+$/;
|
|
1019
|
+
|
|
1020
|
+
function trimTrailingLineTerminators(input: string): string {
|
|
1021
|
+
if (!TRAILING_LINE_TERMINATOR_RE.test(input)) return input;
|
|
1022
|
+
return input.replace(TRAILING_LINE_TERMINATOR_RE, "");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function trimIdentifierStringLeaf(input: unknown): unknown {
|
|
1026
|
+
if (typeof input === "string") {
|
|
1027
|
+
const trimmed = trimTrailingLineTerminators(input);
|
|
1028
|
+
return trimmed === input ? input : trimmed;
|
|
1029
|
+
}
|
|
1030
|
+
if (Array.isArray(input)) {
|
|
1031
|
+
let changed = false;
|
|
1032
|
+
let next = input;
|
|
1033
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
1034
|
+
const item = input[i];
|
|
1035
|
+
if (typeof item !== "string") continue;
|
|
1036
|
+
const trimmed = trimTrailingLineTerminators(item);
|
|
1037
|
+
if (trimmed === item) continue;
|
|
1038
|
+
if (!changed) {
|
|
1039
|
+
next = input.slice();
|
|
1040
|
+
changed = true;
|
|
1041
|
+
}
|
|
1042
|
+
next[i] = trimmed;
|
|
1043
|
+
}
|
|
1044
|
+
return changed ? next : input;
|
|
1045
|
+
}
|
|
1046
|
+
return input;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Recursively strip trailing line terminators from string values whose property
|
|
1051
|
+
* key matches {@link IDENTIFIER_STRING_KEYS}. Runs by property name only
|
|
1052
|
+
* (schema-agnostic) so it fires uniformly across Zod, ArkType, and plain JSON
|
|
1053
|
+
* Schema tools while preserving nested payloads under content-carrying keys.
|
|
1054
|
+
*/
|
|
1055
|
+
function normalizeIdentifierStringWhitespace(value: unknown): { value: unknown; changed: boolean } {
|
|
1056
|
+
if (Array.isArray(value)) {
|
|
1057
|
+
let changed = false;
|
|
1058
|
+
let next = value;
|
|
1059
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
1060
|
+
const normalized = normalizeIdentifierStringWhitespace(value[i]);
|
|
1061
|
+
if (!normalized.changed) continue;
|
|
1062
|
+
if (!changed) {
|
|
1063
|
+
next = [...value];
|
|
1064
|
+
changed = true;
|
|
1065
|
+
}
|
|
1066
|
+
next[i] = normalized.value;
|
|
1067
|
+
}
|
|
1068
|
+
return { value: changed ? next : value, changed };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (value === null || typeof value !== "object") return { value, changed: false };
|
|
1072
|
+
|
|
1073
|
+
const source = value as Record<string, unknown>;
|
|
1074
|
+
let changed = false;
|
|
1075
|
+
let out: Record<string, unknown> = source;
|
|
1076
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
1077
|
+
let nextEntry = entry;
|
|
1078
|
+
if (CONTENT_CARRYING_KEYS.has(key)) continue;
|
|
1079
|
+
if (IDENTIFIER_STRING_KEYS.has(key)) {
|
|
1080
|
+
const trimmed = trimIdentifierStringLeaf(entry);
|
|
1081
|
+
if (trimmed !== entry) nextEntry = trimmed;
|
|
1082
|
+
}
|
|
1083
|
+
const nested = normalizeIdentifierStringWhitespace(nextEntry);
|
|
1084
|
+
if (nested.changed) nextEntry = nested.value;
|
|
1085
|
+
if (nextEntry === entry) continue;
|
|
1086
|
+
if (!changed) {
|
|
1087
|
+
out = { ...source };
|
|
1088
|
+
changed = true;
|
|
1089
|
+
}
|
|
1090
|
+
out[key] = nextEntry;
|
|
1091
|
+
}
|
|
1092
|
+
return { value: changed ? out : value, changed };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
838
1095
|
// ============================================================================
|
|
839
1096
|
// Double-encoded object-key normalization (LLM quirk).
|
|
840
1097
|
// ============================================================================
|
|
@@ -1485,6 +1742,23 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1485
1742
|
changed = true;
|
|
1486
1743
|
}
|
|
1487
1744
|
|
|
1745
|
+
const enumStringNormalization = normalizeEnumStringWhitespace(json, normalizedArgs);
|
|
1746
|
+
if (enumStringNormalization.changed) {
|
|
1747
|
+
normalizedArgs = enumStringNormalization.value;
|
|
1748
|
+
changed = true;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Strip trailing whitespace from string values on well-known
|
|
1752
|
+
// identifier-like property names (paths, URLs, titles). Some models tack
|
|
1753
|
+
// a newline onto a short-identifier arg from stream artifacts; downstream
|
|
1754
|
+
// tools then either fail to stat the target or annotate a "corrected
|
|
1755
|
+
// from" hint the model misreads as tool corruption.
|
|
1756
|
+
const identifierStringNormalization = normalizeIdentifierStringWhitespace(normalizedArgs);
|
|
1757
|
+
if (identifierStringNormalization.changed) {
|
|
1758
|
+
normalizedArgs = identifierStringNormalization.value;
|
|
1759
|
+
changed = true;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1488
1762
|
// Then re-shape JSON-stringified arrays whose schema accepts both string
|
|
1489
1763
|
// and array (e.g. `paths: string | string[]`). Without this, zod accepts
|
|
1490
1764
|
// the literal `'["a","b"]'` as a string and downstream tools treat it as
|
|
@@ -1495,6 +1769,12 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1495
1769
|
changed = true;
|
|
1496
1770
|
}
|
|
1497
1771
|
|
|
1772
|
+
const identifierStringNormalizationAfterArray = normalizeIdentifierStringWhitespace(normalizedArgs);
|
|
1773
|
+
if (identifierStringNormalizationAfterArray.changed) {
|
|
1774
|
+
normalizedArgs = identifierStringNormalizationAfterArray.value;
|
|
1775
|
+
changed = true;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1498
1778
|
// Single-argument tools (e.g. `edit`): if the model put the lone required
|
|
1499
1779
|
// string under a different key, adopt the first string field as that key.
|
|
1500
1780
|
const singleStringNorm = normalizeSingleStringField(json, normalizedArgs);
|
|
@@ -1527,6 +1807,16 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1527
1807
|
normalizedArgs = nullNormalization.value;
|
|
1528
1808
|
}
|
|
1529
1809
|
|
|
1810
|
+
const enumStringNormalizationPass = normalizeEnumStringWhitespace(json, normalizedArgs);
|
|
1811
|
+
if (enumStringNormalizationPass.changed) {
|
|
1812
|
+
normalizedArgs = enumStringNormalizationPass.value;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const identifierStringNormalizationPass = normalizeIdentifierStringWhitespace(normalizedArgs);
|
|
1816
|
+
if (identifierStringNormalizationPass.changed) {
|
|
1817
|
+
normalizedArgs = identifierStringNormalizationPass.value;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1530
1820
|
// Re-run the union-string coercion because `coerceArgsFromIssues` may
|
|
1531
1821
|
// have just unwrapped a JSON-stringified object at the root or inside a
|
|
1532
1822
|
// nested field — exposing `string | string[]` descendants the initial
|
|
@@ -1536,6 +1826,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1536
1826
|
normalizedArgs = stringEncodedArrayNormPass.value;
|
|
1537
1827
|
}
|
|
1538
1828
|
|
|
1829
|
+
const identifierStringNormalizationAfterArrayPass = normalizeIdentifierStringWhitespace(normalizedArgs);
|
|
1830
|
+
if (identifierStringNormalizationAfterArrayPass.changed) {
|
|
1831
|
+
normalizedArgs = identifierStringNormalizationAfterArrayPass.value;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1539
1834
|
// Re-run single-string remap: `coerceArgsFromIssues` may have just
|
|
1540
1835
|
// unwrapped a JSON-stringified root object, exposing a mislabelled lone
|
|
1541
1836
|
// string field the initial pre-pass could not see.
|