@oh-my-pi/pi-ai 16.1.8 → 16.1.9

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.9] - 2026-06-21
6
+
7
+ ### Added
8
+
9
+ - Added `llama.cpp` to the interactive `/login` provider list, accepting an optional API key while defaulting to local no-auth mode.
10
+
11
+ ### Changed
12
+
13
+ - Optimized generated AI tool schemas by collapsing verbose `anyOf` unions into standard `enum` types
14
+
15
+ ### Fixed
16
+
17
+ - Fixed tool-call argument validation dropping nested keys that were accidentally double-encoded
18
+ - Fixed the `moonshot` provider being locked to the international Kimi host (`api.moonshot.ai`): OpenAI-completions requests now honor a `MOONSHOT_BASE_URL` override so users can reach the Kimi China platform (`api.moonshot.cn`), which rejects keys issued for the international endpoint. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883))
19
+ - Fixed tool-call argument validation dropping fields whose object keys were accidentally JSON-encoded a second time (e.g. `{ "\"op\"": "done" }`), which surfaced as spurious missing-required errors. A schema-agnostic pre-validation pass now recursively unwraps such double-encoded keys — through arrays and nested objects, and again after a JSON-string container is parsed — before the unrecognized-key repair can delete them.
20
+
21
+ ### Removed
22
+
23
+ - Removed the `setNextRequestDebugPath`, `clearNextRequestDebugPath`, and `getNextRequestDebugPath` utility functions for request debugging, as request/response recording now relies exclusively on the `PI_REQ_DEBUG` environment variable.
24
+ - Removed Wafer Pass (`wafer-pass`) login support; Wafer Serverless remains available as `wafer-serverless`.
25
+
5
26
  ## [16.1.8] - 2026-06-20
6
27
 
7
28
  ### Changed
package/README.md CHANGED
@@ -62,7 +62,6 @@ Unified LLM API with automatic model discovery, provider configuration, token an
62
62
  - **Hugging Face Inference**
63
63
  - **xAI**
64
64
  - **Venice** (requires `VENICE_API_KEY`)
65
- - **Wafer Pass** (requires `WAFER_PASS_API_KEY`; flat-rate subscription, includes GLM-5.1 and Qwen3.5-397B-A17B)
66
65
  - **Wafer Serverless** (requires `WAFER_SERVERLESS_API_KEY`; pay-as-you-go)
67
66
  - **OpenRouter**
68
67
  - **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`)
@@ -0,0 +1,8 @@
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare function loginLlamaCpp(options: OAuthController): Promise<string>;
3
+ export declare const llamaCppProvider: {
4
+ readonly id: "llama.cpp";
5
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
6
+ readonly envKeys: "LLAMA_CPP_API_KEY";
7
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
8
+ };
@@ -1,2 +1 @@
1
- export declare const loginWaferPass: (options: import("./types").OAuthController) => Promise<string>;
2
1
  export declare const loginWaferServerless: (options: import("./types").OAuthController) => Promise<string>;
@@ -112,6 +112,11 @@ declare const ALL: ({
112
112
  readonly id: "litellm";
113
113
  readonly name: "LiteLLM";
114
114
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
+ } | {
116
+ readonly id: "llama.cpp";
117
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
118
+ readonly envKeys: "LLAMA_CPP_API_KEY";
119
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
120
  } | {
116
121
  readonly id: "lm-studio";
117
122
  readonly name: "LM Studio (Local OpenAI-compatible)";
@@ -226,10 +231,6 @@ declare const ALL: ({
226
231
  readonly id: "vllm";
227
232
  readonly name: "vLLM (Local OpenAI-compatible)";
228
233
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
229
- } | {
230
- readonly id: "wafer-pass";
231
- readonly name: "Wafer Pass (flat-rate subscription)";
232
- readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
233
234
  } | {
234
235
  readonly id: "wafer-serverless";
235
236
  readonly name: "Wafer Serverless (pay-as-you-go)";
@@ -22,9 +22,6 @@ export interface RequestDebugSession {
22
22
  wrapResponse(response: Response): Promise<Response>;
23
23
  }
24
24
  export declare function isRequestDebugEnabled(): boolean;
25
- export declare function setNextRequestDebugPath(requestPath: string): void;
26
- export declare function clearNextRequestDebugPath(): void;
27
- export declare function getNextRequestDebugPath(): string | undefined;
28
25
  export declare function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl;
29
26
  export declare function withRequestDebugFetch<T extends {
30
27
  fetch?: FetchImpl;
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.1.8",
4
+ "version": "16.1.9",
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.1.8",
42
- "@oh-my-pi/pi-utils": "16.1.8",
43
- "@oh-my-pi/pi-wire": "16.1.8",
41
+ "@oh-my-pi/pi-catalog": "16.1.9",
42
+ "@oh-my-pi/pi-utils": "16.1.9",
43
+ "@oh-my-pi/pi-wire": "16.1.9",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -155,6 +155,16 @@ export function resolveOpenAIRequestSetup(
155
155
 
156
156
  let copilotPremiumRequests: number | undefined;
157
157
  let baseUrl = model.baseUrl;
158
+ if (model.provider === "moonshot") {
159
+ // Bundled `moonshot` catalog models hardcode the international endpoint
160
+ // (`api.moonshot.ai`). MOONSHOT_BASE_URL lets users redirect the provider
161
+ // at the China platform (`api.moonshot.cn`), which only accepts China keys
162
+ // and rejects the international host. (#2883)
163
+ const moonshotBaseUrl = $env.MOONSHOT_BASE_URL?.trim();
164
+ if (moonshotBaseUrl) {
165
+ baseUrl = moonshotBaseUrl;
166
+ }
167
+ }
158
168
  if (model.provider === "github-copilot") {
159
169
  apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
160
170
  const copilot = buildCopilotDynamicHeaders({
@@ -0,0 +1,34 @@
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
+ import type { ProviderDefinition } from "./types";
3
+
4
+ const PROVIDER_ID = "llama.cpp";
5
+ const AUTH_URL = "https://github.com/ggml-org/llama.cpp#quick-start";
6
+ const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8080";
7
+ const DEFAULT_LOCAL_TOKEN = "llama-cpp-local";
8
+
9
+ export async function loginLlamaCpp(options: OAuthController): Promise<string> {
10
+ if (!options.onPrompt) {
11
+ throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
12
+ }
13
+ options.onAuth?.({
14
+ url: AUTH_URL,
15
+ instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
16
+ });
17
+ const apiKey = await options.onPrompt({
18
+ message: "Paste your llama.cpp API key (optional for local no-auth)",
19
+ placeholder: DEFAULT_LOCAL_TOKEN,
20
+ allowEmpty: true,
21
+ });
22
+ if (options.signal?.aborted) {
23
+ throw new Error("Login cancelled");
24
+ }
25
+ const trimmed = apiKey.trim();
26
+ return trimmed || DEFAULT_LOCAL_TOKEN;
27
+ }
28
+
29
+ export const llamaCppProvider = {
30
+ id: PROVIDER_ID,
31
+ name: "llama.cpp (Local OpenAI-compatible)",
32
+ envKeys: "LLAMA_CPP_API_KEY",
33
+ login: (cb: OAuthLoginCallbacks) => loginLlamaCpp(cb),
34
+ } as const satisfies ProviderDefinition;
@@ -1,41 +1,15 @@
1
1
  /**
2
- * Wafer login flows.
2
+ * Wafer Serverless login flow.
3
3
  *
4
- * Wafer (https://wafer.ai) exposes a single OpenAI-compatible base URL
5
- * (`https://pass.wafer.ai/v1`) for two SKUs:
6
- *
7
- * - **Wafer Pass** — flat-rate subscription. The key authorizes models whose
8
- * catalog entries carry `wafer.tier = "pass_included"`.
9
- * - **Wafer Serverless** — pay-as-you-go. Superset of Pass; the same `/v1/models`
10
- * endpoint returns the full per-account model list.
11
- *
12
- * Both SKUs issue `wfr_…` keys. The key prefix alone does not distinguish
13
- * tiers — the entitlement is per-account on the server side — so we expose
14
- * two parallel logins / env vars (`WAFER_PASS_API_KEY`, `WAFER_SERVERLESS_API_KEY`)
15
- * mirroring the firepass/fireworks split, letting users with both
16
- * subscriptions switch between them without re-pasting.
17
- *
18
- * Validation uses the shared `/v1/models` endpoint, which works for both
19
- * tiers and is cheap (no token spend).
4
+ * Wafer (https://wafer.ai) exposes a pay-as-you-go OpenAI-compatible SKU at
5
+ * `https://pass.wafer.ai/v1`. Keys use the `wfr_…` prefix and are validated
6
+ * against `/v1/models`, which is cheap (no token spend).
20
7
  */
21
8
  import { createApiKeyLogin } from "../api-key-login";
22
9
 
23
10
  const WAFER_AUTH_URL = "https://wafer.ai/dashboard";
24
11
  const WAFER_MODELS_URL = "https://pass.wafer.ai/v1/models";
25
12
 
26
- export const loginWaferPass = createApiKeyLogin({
27
- providerLabel: "Wafer Pass",
28
- authUrl: WAFER_AUTH_URL,
29
- instructions: "Create or copy your Wafer Pass API key from the Wafer dashboard",
30
- promptMessage: "Paste your Wafer Pass API key",
31
- placeholder: "wfr_...",
32
- validation: {
33
- kind: "models-endpoint",
34
- provider: "Wafer Pass",
35
- modelsUrl: WAFER_MODELS_URL,
36
- },
37
- });
38
-
39
13
  export const loginWaferServerless = createApiKeyLogin({
40
14
  providerLabel: "Wafer Serverless",
41
15
  authUrl: WAFER_AUTH_URL,
@@ -22,6 +22,7 @@ import { kagiProvider } from "./kagi";
22
22
  import { kiloProvider } from "./kilo";
23
23
  import { kimiCodeProvider } from "./kimi-code";
24
24
  import { litellmProvider } from "./litellm";
25
+ import { llamaCppProvider } from "./llama-cpp";
25
26
  import { lmStudioProvider } from "./lm-studio";
26
27
  import { minimaxProvider } from "./minimax";
27
28
  import { minimaxCodeProvider } from "./minimax-code";
@@ -50,7 +51,6 @@ import { umansProvider } from "./umans";
50
51
  import { veniceProvider } from "./venice";
51
52
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
52
53
  import { vllmProvider } from "./vllm";
53
- import { waferPassProvider } from "./wafer-pass";
54
54
  import { waferServerlessProvider } from "./wafer-serverless";
55
55
  import { xaiProvider } from "./xai";
56
56
  import { xaiOauthProvider } from "./xai-oauth";
@@ -95,7 +95,6 @@ const ALL = [
95
95
  xiaomiTokenPlanAmsProvider,
96
96
  xiaomiTokenPlanCnProvider,
97
97
  firepassProvider,
98
- waferPassProvider,
99
98
  deepseekProvider,
100
99
  moonshotProvider,
101
100
  cerebrasProvider,
@@ -122,6 +121,7 @@ const ALL = [
122
121
  ollamaProvider,
123
122
  ollamaCloudProvider,
124
123
  lmStudioProvider,
124
+ llamaCppProvider,
125
125
  vllmProvider,
126
126
  openaiProvider,
127
127
  googleProvider,
package/src/stream.ts CHANGED
@@ -160,7 +160,6 @@ type KeyResolver = string | (() => string | undefined);
160
160
  const LEGACY_ENV_KEYS: Record<string, KeyResolver> = {
161
161
  // Non-provider / search-tool keys and API-name keys not modeled as registry provider defs.
162
162
  "azure-openai-responses": "AZURE_OPENAI_API_KEY",
163
- "llama.cpp": "LLAMA_CPP_API_KEY",
164
163
  exa: "EXA_API_KEY",
165
164
  jina: "JINA_API_KEY",
166
165
  brave: "BRAVE_API_KEY",
@@ -1,6 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
3
  import type { FetchImpl } from "../types";
5
4
 
6
5
  const REQUEST_DEBUG_ENV = "PI_REQ_DEBUG";
@@ -9,7 +8,6 @@ const textEncoder = new TextEncoder();
9
8
  const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
10
9
 
11
10
  let nextSessionId = 1;
12
- let nextRequestDebugPath: string | undefined;
13
11
 
14
12
  type DebugFetch = FetchImpl & { [DEBUG_FETCH_MARKER]?: true };
15
13
  type RequestBodyInit = NonNullable<RequestInit["body"]>;
@@ -55,25 +53,7 @@ function isRequestDebugEnvEnabled(): boolean {
55
53
  }
56
54
 
57
55
  export function isRequestDebugEnabled(): boolean {
58
- return isRequestDebugEnvEnabled() || nextRequestDebugPath !== undefined;
59
- }
60
-
61
- export function setNextRequestDebugPath(requestPath: string): void {
62
- nextRequestDebugPath = requestPath;
63
- }
64
-
65
- export function clearNextRequestDebugPath(): void {
66
- nextRequestDebugPath = undefined;
67
- }
68
-
69
- export function getNextRequestDebugPath(): string | undefined {
70
- return nextRequestDebugPath;
71
- }
72
-
73
- function consumeNextRequestDebugPath(): string | undefined {
74
- const requestPath = nextRequestDebugPath;
75
- nextRequestDebugPath = undefined;
76
- return requestPath;
56
+ return isRequestDebugEnvEnabled();
77
57
  }
78
58
 
79
59
  export function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl {
@@ -249,19 +229,6 @@ function copyResponseMetadata(target: Response, source: Response): void {
249
229
  }
250
230
 
251
231
  async function reserveRequestDebugFile(): Promise<ReservedRequestDebugFile> {
252
- const explicitPath = consumeNextRequestDebugPath();
253
- if (explicitPath) {
254
- await fs.mkdir(path.dirname(explicitPath), { recursive: true });
255
- const handle = await fs.open(explicitPath, "w");
256
- return {
257
- id: nextSessionId++,
258
- requestPath: explicitPath,
259
- responsePath: `${explicitPath}.res.log`,
260
- handle,
261
- overwrite: true,
262
- };
263
- }
264
-
265
232
  for (;;) {
266
233
  const id = nextSessionId++;
267
234
  const requestPath = `rr-session-${id}.json`;
@@ -388,6 +388,73 @@ function inferBareEnumScalarType(obj: Record<string, unknown>): void {
388
388
  if (inferred !== undefined) obj.type = inferred;
389
389
  }
390
390
 
391
+ /**
392
+ * ArkType serializes a *described* literal union — `type.enumerated(...).describe(d)`
393
+ * or a `"a" | "b"` union carrying a description — as an `anyOf` of
394
+ * `{ const, description }` branches that repeat the description on every branch
395
+ * *and* the union root. The meta is distributed across the union's constituents
396
+ * at the type level (each `unit` node inherits it), so the duplication is baked
397
+ * in before serialization rather than added by this pipeline.
398
+ *
399
+ * Collapse such a homogeneous all-`const` union into one typed
400
+ * `{ type, enum, description }` node: a shorter wire and a single description in
401
+ * the place providers expect it. The collapse is conservative — applied only
402
+ * when it is lossless:
403
+ * - every branch is a bare `{ const }` (optionally `{ const, description }`),
404
+ * - all branch values share one scalar JSON type (so `enum` gets a `type`,
405
+ * which Gemini/Vertex require),
406
+ * - branch descriptions are either all absent or all identical,
407
+ * so a union whose branches carry *distinct* per-variant descriptions is left
408
+ * untouched (a flat `enum` has nowhere to keep them). The union root's own
409
+ * description wins when present; otherwise the shared branch description is kept.
410
+ */
411
+ function collapseConstUnionAnyOf(obj: Record<string, unknown>): void {
412
+ // `hasSchemaDefiningSibling` already rejects a sibling `enum`/`const`/etc.; it
413
+ // does not list `type`, so guard it here — collapsing would overwrite a
414
+ // wrapper `type` constraint paired with the `anyOf`.
415
+ if (hasSchemaDefiningSibling(obj) || "type" in obj) return;
416
+ const variants = obj.anyOf;
417
+ if (!Array.isArray(variants) || variants.length < 2) return;
418
+
419
+ const values: unknown[] = [];
420
+ let branchDescription: string | undefined;
421
+ let describedCount = 0;
422
+ for (const variant of variants) {
423
+ if (!isSchemaRecord(variant) || !Object.hasOwn(variant, "const")) return;
424
+ for (const key in variant) {
425
+ if (key !== "const" && key !== "description") return; // extra constraints — not a bare const
426
+ }
427
+ const desc = variant.description;
428
+ if (typeof desc === "string") {
429
+ if (describedCount === 0) branchDescription = desc;
430
+ else if (desc !== branchDescription) return; // distinct per-variant descriptions — preserve them
431
+ describedCount++;
432
+ }
433
+ values.push(variant.const);
434
+ }
435
+ if (describedCount !== 0 && describedCount !== variants.length) return; // mixed described/undescribed
436
+ // A shared branch description that disagrees with the union root's own
437
+ // description would be silently dropped by the collapse — keep the anyOf so
438
+ // neither annotation is lost. (Equal descriptions, the ArkType case, collapse.)
439
+ if (
440
+ describedCount === variants.length &&
441
+ typeof obj.description === "string" &&
442
+ obj.description !== branchDescription
443
+ ) {
444
+ return;
445
+ }
446
+
447
+ const scalarType = homogeneousEnumScalarType(values);
448
+ if (scalarType === undefined) return; // mixed / non-scalar (incl. null) — leave as anyOf
449
+
450
+ delete obj.anyOf;
451
+ obj.type = scalarType;
452
+ obj.enum = values;
453
+ if (typeof obj.description !== "string" && branchDescription !== undefined) {
454
+ obj.description = branchDescription;
455
+ }
456
+ }
457
+
391
458
  function walk(node: unknown, zodCleanup: boolean): void {
392
459
  if (Array.isArray(node)) {
393
460
  for (const child of node) walk(child, zodCleanup);
@@ -397,6 +464,7 @@ function walk(node: unknown, zodCleanup: boolean): void {
397
464
  const obj = node as Record<string, unknown>;
398
465
  rewriteNullableScalarAnyOf(obj);
399
466
  inferBareEnumScalarType(obj);
467
+ collapseConstUnionAnyOf(obj);
400
468
 
401
469
  if (zodCleanup) {
402
470
  // Drop noise injected for `z.number().int()`.
@@ -834,6 +834,105 @@ function normalizeOptionalNullsForSchema(
834
834
  return { value: changed ? nextValue : value, changed };
835
835
  }
836
836
 
837
+ // ============================================================================
838
+ // Double-encoded object-key normalization (LLM quirk).
839
+ // ============================================================================
840
+ //
841
+ // LLMs occasionally serialize an object key one time too many, so the property
842
+ // NAME arrives as the JSON encoding of the real name — literal quote characters
843
+ // and all (e.g. `{ "\"op\"": "done" }` decodes to the JS key `"op"`). The
844
+ // schema never matches such a key, so it reads as an unrecognized extra and is
845
+ // dropped by the unrecognized-key repair, later surfacing as a spurious
846
+ // missing-required error. We walk the whole value (arrays + nested objects)
847
+ // and rename any key that is itself the JSON encoding of a plain string back to
848
+ // that string.
849
+ // ============================================================================
850
+
851
+ /** Max layers of accidental JSON-encoding to peel off a single object key. */
852
+ const MAX_KEY_DECODE_DEPTH = 3;
853
+
854
+ /**
855
+ * If `key` is the JSON encoding of a plain string (quote-wrapped and
856
+ * `JSON.parse`s to a string), return the decoded string; otherwise null. Peels
857
+ * up to {@link MAX_KEY_DECODE_DEPTH} nested encodings so multiply-encoded keys
858
+ * collapse in one pass. Conservative: any key that is not a quote-wrapped JSON
859
+ * string literal is left untouched.
860
+ */
861
+ function decodeDoubleEncodedKey(key: string): string | null {
862
+ let current = key;
863
+ let decoded: string | null = null;
864
+ for (let depth = 0; depth < MAX_KEY_DECODE_DEPTH; depth += 1) {
865
+ if (current.length < 2 || current[0] !== '"' || current[current.length - 1] !== '"') break;
866
+ let parsed: unknown;
867
+ try {
868
+ parsed = JSON.parse(current);
869
+ } catch {
870
+ break;
871
+ }
872
+ if (typeof parsed !== "string") break;
873
+ current = parsed;
874
+ decoded = current;
875
+ }
876
+ return decoded;
877
+ }
878
+
879
+ /**
880
+ * Recursively unwrap object keys that were accidentally JSON-encoded an extra
881
+ * time. Schema-agnostic by design: such keys are dropped before any schema pass
882
+ * can map them, so this runs first. A key is only renamed when the decoded name
883
+ * differs and does not already exist on the same object — renaming would
884
+ * otherwise clobber a sibling and silently lose data.
885
+ */
886
+ function normalizeDoubleEncodedKeys(value: unknown): { value: unknown; changed: boolean } {
887
+ if (Array.isArray(value)) {
888
+ let changed = false;
889
+ let next = value;
890
+ for (let i = 0; i < value.length; i += 1) {
891
+ const normalized = normalizeDoubleEncodedKeys(value[i]);
892
+ if (!normalized.changed) continue;
893
+ if (!changed) {
894
+ next = [...value];
895
+ changed = true;
896
+ }
897
+ next[i] = normalized.value;
898
+ }
899
+ return { value: changed ? next : value, changed };
900
+ }
901
+
902
+ if (value === null || typeof value !== "object") return { value, changed: false };
903
+
904
+ const source = value as Record<string, unknown>;
905
+ let changed = false;
906
+ const out: Record<string, unknown> = {};
907
+ for (const [key, entry] of Object.entries(source)) {
908
+ const normalizedChild = normalizeDoubleEncodedKeys(entry);
909
+ const nextChild = normalizedChild.changed ? normalizedChild.value : entry;
910
+
911
+ const decodedKey = decodeDoubleEncodedKey(key);
912
+ // `Object.hasOwn` (not `in`) so a decoded `constructor`/`toString` is not
913
+ // mistaken for a collision via the prototype chain.
914
+ const targetKey =
915
+ decodedKey !== null &&
916
+ decodedKey !== key &&
917
+ !Object.hasOwn(source, decodedKey) &&
918
+ !Object.hasOwn(out, decodedKey)
919
+ ? decodedKey
920
+ : key;
921
+
922
+ if (targetKey !== key || normalizedChild.changed) changed = true;
923
+ // `defineProperty` so a decoded `__proto__` key becomes an own property
924
+ // instead of mutating the result object's prototype.
925
+ Object.defineProperty(out, targetKey, {
926
+ value: nextChild,
927
+ writable: true,
928
+ enumerable: true,
929
+ configurable: true,
930
+ });
931
+ }
932
+
933
+ return { value: changed ? out : value, changed };
934
+ }
935
+
837
936
  // ============================================================================
838
937
  // String-encoded array coercion for union(string, array) schemas.
839
938
  // ============================================================================
@@ -922,8 +1021,15 @@ function normalizeStringEncodedArrayUnions(schema: unknown, value: unknown): { v
922
1021
  if (!trimmed.startsWith("[")) return { value, changed: false };
923
1022
  try {
924
1023
  const parsed = JSON.parse(trimmed) as unknown;
925
- if (Array.isArray(parsed) && parsedArrayMatchesArrayBranch(schemaObject, parsed)) {
926
- return { value: parsed, changed: true };
1024
+ if (Array.isArray(parsed)) {
1025
+ // Unwrap any double-encoded object keys inside the parsed array
1026
+ // before the branch-match check; otherwise an `array<object>`
1027
+ // branch fails to validate and the value silently stays on the
1028
+ // string branch.
1029
+ const candidate = normalizeDoubleEncodedKeys(parsed).value as unknown[];
1030
+ if (parsedArrayMatchesArrayBranch(schemaObject, candidate)) {
1031
+ return { value: candidate, changed: true };
1032
+ }
927
1033
  }
928
1034
  } catch {
929
1035
  // Not valid JSON — leave the string alone for the validator to handle.
@@ -1312,6 +1418,18 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1312
1418
  // placeholders for "no value" even when validation would otherwise pass.
1313
1419
  let normalizedArgs: unknown = originalArgs;
1314
1420
  let changed = false;
1421
+
1422
+ // Unwrap accidentally double-JSON-encoded object keys before any schema
1423
+ // pass. LLMs sometimes emit `{ "\"op\"": "done" }`, so the property name
1424
+ // arrives quote-wrapped; left alone it reads as an unrecognized key, gets
1425
+ // dropped by the coercion repair, and re-surfaces as a missing-required
1426
+ // error. Running first means every later pass sees the corrected names.
1427
+ const keyNormalization = normalizeDoubleEncodedKeys(normalizedArgs);
1428
+ if (keyNormalization.changed) {
1429
+ normalizedArgs = keyNormalization.value;
1430
+ changed = true;
1431
+ }
1432
+
1315
1433
  const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
1316
1434
  if (initialNormalization.changed) {
1317
1435
  normalizedArgs = initialNormalization.value;
@@ -1338,6 +1456,15 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1338
1456
  normalizedArgs = coercion.value;
1339
1457
  changed = true;
1340
1458
 
1459
+ // `coerceArgsFromIssues` may have just parsed a JSON-string container at
1460
+ // the root or a nested field, exposing double-encoded keys the initial
1461
+ // pass could not reach. Re-unwrap before the unrecognized-key repair on
1462
+ // the next validation pass would delete them.
1463
+ const keyNormalizationPass = normalizeDoubleEncodedKeys(normalizedArgs);
1464
+ if (keyNormalizationPass.changed) {
1465
+ normalizedArgs = keyNormalizationPass.value;
1466
+ }
1467
+
1341
1468
  const nullNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
1342
1469
  if (nullNormalization.changed) {
1343
1470
  normalizedArgs = nullNormalization.value;
@@ -1,6 +0,0 @@
1
- import type { OAuthLoginCallbacks } from "./oauth/types";
2
- export declare const waferPassProvider: {
3
- readonly id: "wafer-pass";
4
- readonly name: "Wafer Pass (flat-rate subscription)";
5
- readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
6
- };
@@ -1,12 +0,0 @@
1
- import type { OAuthLoginCallbacks } from "./oauth/types";
2
- import type { ProviderDefinition } from "./types";
3
-
4
- export const waferPassProvider = {
5
- id: "wafer-pass",
6
- name: "Wafer Pass (flat-rate subscription)",
7
- login: async (cb: OAuthLoginCallbacks) => {
8
- // Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
9
- const { loginWaferPass } = await import("./oauth/wafer");
10
- return loginWaferPass(cb);
11
- },
12
- } as const satisfies ProviderDefinition;