@oh-my-pi/pi-ai 16.3.3 → 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 CHANGED
@@ -2,6 +2,31 @@
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
+
16
+ ## [16.3.4] - 2026-07-03
17
+
18
+ ### Added
19
+
20
+ - Added support for Baseten as an AI provider
21
+
22
+ ### Changed
23
+
24
+ - Improved Claude usage reliability by removing proactive hard-blocking for Fable and Mythos tiers
25
+
26
+ ### Fixed
27
+
28
+ - Fixed Anthropic OAuth account rotation to exclude unreliable model-scoped Fable/Mythos weekly caps from proactive hard-blocking, ensuring they act only as ranking priority hints while still allowing reactive 429-fallback to rotate and reach serviceable siblings.
29
+
5
30
  ## [16.3.3] - 2026-07-02
6
31
 
7
32
  ### 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;
@@ -0,0 +1,7 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginBaseten: (options: import("./oauth").OAuthController) => Promise<string>;
3
+ export declare const basetenProvider: {
4
+ readonly id: "baseten";
5
+ readonly name: "Baseten";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ };
@@ -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 {
@@ -29,6 +29,10 @@ declare const ALL: ({
29
29
  } | {
30
30
  readonly id: "azure";
31
31
  readonly name: "Azure OpenAI";
32
+ } | {
33
+ readonly id: "baseten";
34
+ readonly name: "Baseten";
35
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
32
36
  } | {
33
37
  readonly id: "cerebras";
34
38
  readonly name: "Cerebras";
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.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.3",
42
- "@oh-my-pi/pi-utils": "16.3.3",
43
- "@oh-my-pi/pi-wire": "16.3.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
  },
@@ -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 { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
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: { url: string; instructions?: string }) => void;
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
- ...(effectiveStrict && { strict: true }),
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 && { strict: true }),
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
- ...(effectiveStrict && { strict: true }),
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;
@@ -0,0 +1,22 @@
1
+ import { createApiKeyLogin } from "./api-key-login";
2
+ import type { OAuthLoginCallbacks } from "./oauth/types";
3
+ import type { ProviderDefinition } from "./types";
4
+
5
+ export const loginBaseten = createApiKeyLogin({
6
+ providerLabel: "Baseten",
7
+ authUrl: "https://app.baseten.co/settings/api_keys",
8
+ instructions: "Copy your API key from the Baseten dashboard",
9
+ promptMessage: "Paste your Baseten API key",
10
+ placeholder: "bt_...",
11
+ validation: {
12
+ kind: "models-endpoint",
13
+ provider: "Baseten",
14
+ modelsUrl: "https://inference.baseten.co/v1/models",
15
+ },
16
+ });
17
+
18
+ export const basetenProvider = {
19
+ id: "baseten",
20
+ name: "Baseten",
21
+ login: (cb: OAuthLoginCallbacks) => loginBaseten(cb),
22
+ } as const satisfies ProviderDefinition;
@@ -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(expectedState: string): Promise<{ server: Bun.Server<unknown>; redirectUri: string }> {
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}:${this.preferredPort}${this.callbackPath}`;
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.port;
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
 
@@ -4,6 +4,7 @@ import { alibabaCodingPlanProvider } from "./alibaba-coding-plan";
4
4
  import { amazonBedrockProvider } from "./amazon-bedrock";
5
5
  import { anthropicProvider } from "./anthropic";
6
6
  import { azureProvider } from "./azure";
7
+ import { basetenProvider } from "./baseten";
7
8
  import { cerebrasProvider } from "./cerebras";
8
9
  import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway";
9
10
  import { coreWeaveProvider } from "./coreweave";
@@ -105,6 +106,7 @@ const ALL = [
105
106
  deepseekProvider,
106
107
  moonshotProvider,
107
108
  cerebrasProvider,
109
+ basetenProvider,
108
110
  fireworksProvider,
109
111
  togetherProvider,
110
112
  nvidiaProvider,
@@ -614,6 +614,23 @@ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRanki
614
614
  );
615
615
  }
616
616
 
617
+ /**
618
+ * Exclude Fable and Mythos tier weekly caps from proactive hard-blocking
619
+ * (gating) because they are notoriously unreliable (they report 100% exhausted
620
+ * while the account can still serve requests). They stay available for ranking
621
+ * pressure in findWindowLimits via scopeClaudeLimitsForModel.
622
+ */
623
+ function scopeClaudeLimitsForModelHardBlock(
624
+ report: UsageReport,
625
+ context: CredentialRankingContext | undefined,
626
+ ): UsageLimit[] {
627
+ const kind = getClaudeModelKind(context);
628
+ const excludeHardBlock = kind === "fable" || kind === "mythos";
629
+ return report.limits.filter(
630
+ limit => limit.scope.shared === true || (kind !== undefined && limit.scope.tier === kind && !excludeHardBlock),
631
+ );
632
+ }
633
+
617
634
  function rankingUsedFraction(limit: UsageLimit): number {
618
635
  const fraction = resolveUsedFraction(limit);
619
636
  if (typeof fraction !== "number" || !Number.isFinite(fraction)) return 0.5;
@@ -664,7 +681,7 @@ export const claudeRankingStrategy: CredentialRankingStrategy = {
664
681
  const secondary = findClaudeSecondaryLimit(report, context);
665
682
  return { primary, secondary };
666
683
  },
667
- scopeLimits: scopeClaudeLimitsForModel,
684
+ scopeLimits: scopeClaudeLimitsForModelHardBlock,
668
685
  /**
669
686
  * Fable/Mythos usage-limit errors map to tier-local weekly counters. Scope
670
687
  * reactive backoff blocks for those tiers, mirroring the per-counter
@@ -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
 
@@ -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.