@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 +21 -0
- package/README.md +0 -1
- package/dist/types/registry/llama-cpp.d.ts +8 -0
- package/dist/types/registry/oauth/wafer.d.ts +0 -1
- package/dist/types/registry/registry.d.ts +5 -4
- package/dist/types/utils/request-debug.d.ts +0 -3
- package/package.json +4 -4
- package/src/providers/openai-shared.ts +10 -0
- package/src/registry/llama-cpp.ts +34 -0
- package/src/registry/oauth/wafer.ts +4 -30
- package/src/registry/registry.ts +2 -2
- package/src/stream.ts +0 -1
- package/src/utils/request-debug.ts +1 -34
- package/src/utils/schema/wire.ts +68 -0
- package/src/utils/validation.ts +129 -2
- package/dist/types/registry/wafer-pass.d.ts +0 -6
- package/src/registry/wafer-pass.ts +0 -12
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
|
+
};
|
|
@@ -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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
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
|
|
2
|
+
* Wafer Serverless login flow.
|
|
3
3
|
*
|
|
4
|
-
* Wafer (https://wafer.ai) exposes a
|
|
5
|
-
*
|
|
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,
|
package/src/registry/registry.ts
CHANGED
|
@@ -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()
|
|
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`;
|
package/src/utils/schema/wire.ts
CHANGED
|
@@ -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()`.
|
package/src/utils/validation.ts
CHANGED
|
@@ -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)
|
|
926
|
-
|
|
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,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;
|