@gajae-code/ai 0.15.6 → 0.16.1
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 +43 -0
- package/dist/types/adapter-internals/aws-region.d.ts +7 -0
- package/dist/types/core.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/provider-models/openai-compat.d.ts +1 -0
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +27 -1
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +21 -0
- package/dist/types/utils/h2-fetch.d.ts +7 -0
- package/dist/types/utils/schema/normalize.d.ts +0 -5
- package/dist/types/utils/sqlite-errors.d.ts +4 -0
- package/package.json +3 -3
- package/src/adapter-internals/aws-region.d.ts +7 -0
- package/src/adapter-internals/aws-region.ts +14 -0
- package/src/auth-broker/server.ts +10 -1
- package/src/auth-storage.ts +14 -14
- package/src/core.ts +1 -0
- package/src/index.ts +1 -0
- package/src/model-thinking.ts +8 -0
- package/src/models.json +201 -3
- package/src/provider-models/openai-compat.ts +93 -8
- package/src/providers/amazon-bedrock.ts +5 -1
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/aws-credentials.ts +6 -0
- package/src/providers/cursor.d.ts +27 -1
- package/src/providers/cursor.ts +234 -17
- package/src/providers/google-gemini-headers.d.ts +1 -1
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/kiro-api-key.ts +33 -8
- package/src/providers/kiro-codewhisperer.ts +4 -1
- package/src/providers/openai-codex-responses.d.ts +6 -0
- package/src/providers/openai-codex-responses.ts +17 -2
- package/src/providers/pi-native-client.ts +24 -1
- package/src/utils/discovery/antigravity.ts +10 -1
- package/src/utils/discovery/openai-compatible.ts +38 -0
- package/src/utils/h2-fetch.ts +10 -0
- package/src/utils/oauth/callback-server.ts +8 -1
- package/src/utils/oauth/glm-zcode.ts +1 -1
- package/src/utils/oauth/kiro.ts +91 -22
- package/src/utils/schema/dereference.ts +169 -49
- package/src/utils/schema/draft.ts +46 -23
- package/src/utils/schema/normalize.d.ts +0 -5
- package/src/utils/schema/normalize.ts +396 -119
- package/src/utils/schema/types.ts +3 -1
- package/src/utils/schema/zod-decontaminate.ts +83 -29
- package/src/utils/sqlite-errors.d.ts +4 -0
- package/src/utils/sqlite-errors.ts +13 -0
- package/src/utils/tool-choice-capability.ts +2 -3
|
@@ -52,6 +52,44 @@ export function isSafeCatalogModelId(value: unknown): value is string {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The two wire families a mixed OpenAI-compatible gateway (e.g. CLIProxyAPI)
|
|
57
|
+
* can front. A gateway exposes an OpenAI-shaped `/v1/models` catalog but may
|
|
58
|
+
* proxy Anthropic models that must be driven through the Anthropic Messages
|
|
59
|
+
* transport rather than OpenAI Chat Completions.
|
|
60
|
+
*/
|
|
61
|
+
export type DiscoveredApiFamily = "anthropic-messages" | "openai-completions";
|
|
62
|
+
|
|
63
|
+
const ANTHROPIC_OWNER_PATTERN = /\banthropic\b/i;
|
|
64
|
+
const OPENAI_OWNER_PATTERN = /\b(openai|open-ai)\b/i;
|
|
65
|
+
// Anthropic model ids are consistently `claude-*` across every gateway; the
|
|
66
|
+
// `owned_by` owner string is the primary signal and the id is the fallback.
|
|
67
|
+
const ANTHROPIC_MODEL_ID_PATTERN = /(^|[/:])claude[-.]/i;
|
|
68
|
+
const OPENAI_MODEL_ID_PATTERN = /(^|[/:])(gpt[-.]?|o[1-9]|codex|text-|chatgpt|davinci|dall-e|gpt-image|whisper|tts-)/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Infer the wire API family for one discovered model on a mixed
|
|
72
|
+
* OpenAI-compatible gateway.
|
|
73
|
+
*
|
|
74
|
+
* Uses the `owned_by` owner string first (authoritative when the gateway
|
|
75
|
+
* populates it — `"anthropic"` / `"openai"`), then falls back to the model id
|
|
76
|
+
* (`claude-*` → Anthropic, `gpt-*`/`o1`/`codex`/… → OpenAI). Returns
|
|
77
|
+
* `undefined` when neither signal is conclusive so the caller can keep the
|
|
78
|
+
* provider-level default instead of guessing.
|
|
79
|
+
*/
|
|
80
|
+
export function detectDiscoveredApiFamily(entry: {
|
|
81
|
+
id?: unknown;
|
|
82
|
+
owned_by?: unknown;
|
|
83
|
+
}): DiscoveredApiFamily | undefined {
|
|
84
|
+
const owner = typeof entry.owned_by === "string" ? entry.owned_by : "";
|
|
85
|
+
if (ANTHROPIC_OWNER_PATTERN.test(owner)) return "anthropic-messages";
|
|
86
|
+
if (OPENAI_OWNER_PATTERN.test(owner)) return "openai-completions";
|
|
87
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
88
|
+
if (ANTHROPIC_MODEL_ID_PATTERN.test(id)) return "anthropic-messages";
|
|
89
|
+
if (OPENAI_MODEL_ID_PATTERN.test(id)) return "openai-completions";
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
/**
|
|
56
94
|
* Minimal OpenAI-style model entry shape consumed by discovery.
|
|
57
95
|
*
|
package/src/utils/h2-fetch.ts
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
|
|
15
15
|
* codes as h2-fallback triggers as well.
|
|
16
16
|
*
|
|
17
|
+
* ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
|
|
18
|
+
* the TLS handshake entirely when the client offers ALPN h2. Bun reports that
|
|
19
|
+
* abort as `UNKNOWN_CERTIFICATE_VERIFICATION_ERROR` even though the host's
|
|
20
|
+
* certificate chain verifies fine over h1 (issue #5178), so that code is a
|
|
21
|
+
* fallback trigger too — never a reason to accept a bad certificate: the h1
|
|
22
|
+
* attempt below performs full verification on its own.
|
|
23
|
+
*
|
|
17
24
|
* Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
|
|
18
25
|
* skip the attempt entirely — avoids the throw/retry round-trip for localhost.
|
|
19
26
|
*
|
|
@@ -36,6 +43,9 @@ export function installH2Fetch(): void {
|
|
|
36
43
|
"ConnectionRefused", // Server refused the h2 connection
|
|
37
44
|
"ConnectionReset", // Server reset during h2 handshake
|
|
38
45
|
"ConnectionClosed", // Server closed before h2 response
|
|
46
|
+
// Bun's h2 client reports an ALPN-refusing host's TLS abort with this
|
|
47
|
+
// code; the h1 fallback below re-verifies the certificate itself.
|
|
48
|
+
"UNKNOWN_CERTIFICATE_VERIFICATION_ERROR",
|
|
39
49
|
]);
|
|
40
50
|
const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
41
51
|
if (!isHttps(input)) return original(input, init);
|
|
@@ -19,6 +19,13 @@ const DEFAULT_TIMEOUT = 300_000;
|
|
|
19
19
|
const DEFAULT_HOSTNAME = "localhost";
|
|
20
20
|
const CALLBACK_PATH = "/callback";
|
|
21
21
|
|
|
22
|
+
function serializeScriptData(value: unknown): string {
|
|
23
|
+
return JSON.stringify(value)
|
|
24
|
+
.replaceAll("<", "\\u003c")
|
|
25
|
+
.replaceAll("\u2028", "\\u2028")
|
|
26
|
+
.replaceAll("\u2029", "\\u2029");
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export type CallbackResult = { code: string; state: string };
|
|
23
30
|
|
|
24
31
|
export interface OAuthCallbackFlowOptions {
|
|
@@ -259,7 +266,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
259
266
|
});
|
|
260
267
|
|
|
261
268
|
return new Response(
|
|
262
|
-
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__",
|
|
269
|
+
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => serializeScriptData(resultState)),
|
|
263
270
|
{
|
|
264
271
|
status: resultState.ok ? 200 : 500,
|
|
265
272
|
headers: { "Content-Type": "text/html" },
|
|
@@ -387,7 +387,7 @@ export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
|
|
|
387
387
|
return {
|
|
388
388
|
url: `${authorizeUrl}?${params.toString()}`,
|
|
389
389
|
instructions:
|
|
390
|
-
"Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. Because this CLI cannot receive the zcode:// redirect, paste the final redirect URL or authorization code when prompted.",
|
|
390
|
+
"Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. Because this CLI cannot receive the zcode:// redirect, paste the final redirect URL or authorization code when prompted. If the ZCode desktop app is installed, cancel the browser's prompt to open it: the app exchanges the single-use code itself and the pasted code is then rejected (broker error 2007).",
|
|
391
391
|
};
|
|
392
392
|
}
|
|
393
393
|
|
package/src/utils/oauth/kiro.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and AWS public documentation, not from any third-party reference.
|
|
9
9
|
*/
|
|
10
10
|
import { scheduler } from "node:timers/promises";
|
|
11
|
+
import { assertAwsRegionLabel } from "../../adapter-internals/aws-region";
|
|
11
12
|
import type { OAuthCredentials } from "./types";
|
|
12
13
|
|
|
13
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -72,23 +73,38 @@ interface CreateTokenError {
|
|
|
72
73
|
error_uri?: string;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
interface CreateTokenResult {
|
|
77
|
+
response: Response;
|
|
78
|
+
status: number;
|
|
79
|
+
data: CreateTokenSuccess | CreateTokenError;
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
83
|
// Typed SSO OIDC error names from the published service model
|
|
77
84
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
85
|
|
|
79
86
|
const SSO_OIDC_FATAL_ERRORS = new Set([
|
|
80
87
|
"access_denied_exception",
|
|
88
|
+
"access_denied",
|
|
81
89
|
"expired_token_exception",
|
|
90
|
+
"expired_token",
|
|
82
91
|
"internal_server_exception",
|
|
92
|
+
"server_error",
|
|
83
93
|
"invalid_client_exception",
|
|
94
|
+
"invalid_client",
|
|
84
95
|
"invalid_client_metadata_exception",
|
|
85
96
|
"invalid_grant_exception",
|
|
97
|
+
"invalid_grant",
|
|
86
98
|
"invalid_redirect_uri_exception",
|
|
87
99
|
"invalid_request_exception",
|
|
100
|
+
"invalid_request",
|
|
88
101
|
"invalid_request_region_exception",
|
|
89
102
|
"invalid_scope_exception",
|
|
103
|
+
"invalid_scope",
|
|
90
104
|
"unauthorized_client_exception",
|
|
105
|
+
"unauthorized_client",
|
|
91
106
|
"unsupported_grant_type_exception",
|
|
107
|
+
"unsupported_grant_type",
|
|
92
108
|
]);
|
|
93
109
|
|
|
94
110
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -133,6 +149,7 @@ export async function registerClient(
|
|
|
133
149
|
method: "POST",
|
|
134
150
|
headers: { "Content-Type": "application/json" },
|
|
135
151
|
body: JSON.stringify(body),
|
|
152
|
+
redirect: "error",
|
|
136
153
|
signal,
|
|
137
154
|
});
|
|
138
155
|
|
|
@@ -180,6 +197,7 @@ export async function startDeviceAuthorization(
|
|
|
180
197
|
method: "POST",
|
|
181
198
|
headers: { "Content-Type": "application/json" },
|
|
182
199
|
body: JSON.stringify(body),
|
|
200
|
+
redirect: "error",
|
|
183
201
|
signal,
|
|
184
202
|
});
|
|
185
203
|
|
|
@@ -227,33 +245,54 @@ export async function pollForToken(
|
|
|
227
245
|
deviceCode,
|
|
228
246
|
};
|
|
229
247
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
const requestSignal = AbortSignal.any([
|
|
249
|
+
...(signal ? [signal] : []),
|
|
250
|
+
AbortSignal.timeout(Math.max(1, deadline - Date.now())),
|
|
251
|
+
]);
|
|
252
|
+
let result: CreateTokenResult;
|
|
253
|
+
try {
|
|
254
|
+
result = await createTokenOnce(url, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "Content-Type": "application/json" },
|
|
257
|
+
body: JSON.stringify(body),
|
|
258
|
+
signal: requestSignal,
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
262
|
+
if (Date.now() >= deadline) break;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
238
265
|
|
|
239
|
-
if (
|
|
266
|
+
if (Date.now() >= deadline) break;
|
|
267
|
+
const { status, data } = result;
|
|
268
|
+
|
|
269
|
+
if ("accessToken" in data) {
|
|
270
|
+
if (
|
|
271
|
+
status < 200 ||
|
|
272
|
+
status >= 300 ||
|
|
273
|
+
"error" in data ||
|
|
274
|
+
data.accessToken.length === 0 ||
|
|
275
|
+
!Number.isFinite(data.expiresIn) ||
|
|
276
|
+
data.expiresIn <= 0
|
|
277
|
+
) {
|
|
278
|
+
throw new Error("SSO OIDC CreateToken: invalid success response");
|
|
279
|
+
}
|
|
240
280
|
return data;
|
|
241
281
|
}
|
|
242
282
|
|
|
243
283
|
if ("error" in data) {
|
|
284
|
+
if (status !== 400) throw new Error(oidcRequestFailure(url, result.response));
|
|
244
285
|
const errorCode = data.error;
|
|
245
286
|
if (errorCode === "authorization_pending") continue;
|
|
246
287
|
if (errorCode === "slow_down") {
|
|
247
|
-
currentInterval
|
|
288
|
+
currentInterval += 5_000;
|
|
248
289
|
continue;
|
|
249
290
|
}
|
|
250
291
|
if (SSO_OIDC_FATAL_ERRORS.has(errorCode)) {
|
|
251
|
-
|
|
252
|
-
throw new Error(`SSO OIDC token error: ${errorCode}${desc}`);
|
|
292
|
+
throw new Error(`SSO OIDC token error: ${errorCode}`);
|
|
253
293
|
}
|
|
254
294
|
// Unknown error — fail closed
|
|
255
|
-
|
|
256
|
-
throw new Error(`SSO OIDC unrecognized token error: ${errorCode}${desc}`);
|
|
295
|
+
throw new Error(`SSO OIDC unrecognized token error: ${errorCode}`);
|
|
257
296
|
}
|
|
258
297
|
|
|
259
298
|
throw new Error("SSO OIDC CreateToken: unrecognized response shape");
|
|
@@ -292,6 +331,7 @@ export async function refreshKiroToken(credentials: OAuthCredentials): Promise<O
|
|
|
292
331
|
method: "POST",
|
|
293
332
|
headers: { "Content-Type": "application/json" },
|
|
294
333
|
body: JSON.stringify(body),
|
|
334
|
+
redirect: "error",
|
|
295
335
|
});
|
|
296
336
|
|
|
297
337
|
const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
|
|
@@ -430,19 +470,48 @@ export function importSsoCacheToken(): OAuthCredentials | undefined {
|
|
|
430
470
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
431
471
|
|
|
432
472
|
function ssoOidcEndpoint(region: string, pathSuffix: string): string {
|
|
473
|
+
assertAwsRegionLabel(region);
|
|
433
474
|
return `https://oidc.${region}.amazonaws.com${pathSuffix}`;
|
|
434
475
|
}
|
|
435
476
|
|
|
436
477
|
async function fetchOidc(url: string, init: RequestInit & { signal?: AbortSignal }): Promise<Response> {
|
|
437
|
-
const response = await fetch(url, init);
|
|
478
|
+
const response = await fetch(url, { ...init, redirect: "error" });
|
|
438
479
|
if (!response.ok) {
|
|
439
|
-
|
|
440
|
-
try {
|
|
441
|
-
errorBody = await response.text();
|
|
442
|
-
} catch {}
|
|
443
|
-
throw new Error(
|
|
444
|
-
`SSO OIDC request to ${url} failed: ${response.status} ${response.statusText}: ${errorBody.slice(0, 500)}`,
|
|
445
|
-
);
|
|
480
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
446
481
|
}
|
|
447
482
|
return response;
|
|
448
483
|
}
|
|
484
|
+
|
|
485
|
+
function oidcRequestFailure(url: string, response: Response): string {
|
|
486
|
+
return `SSO OIDC request to ${url} failed: ${response.status} ${response.statusText}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* `CreateToken` reports the in-progress device-code states (`authorization_pending`,
|
|
491
|
+
* `slow_down`) as HTTP 400 responses whose body carries the error code, so the poll
|
|
492
|
+
* loop must read the payload instead of treating a non-2xx status as fatal.
|
|
493
|
+
* Non-2xx responses without an `error` field still fail closed.
|
|
494
|
+
*/
|
|
495
|
+
async function createTokenOnce(url: string, init: RequestInit & { signal?: AbortSignal }): Promise<CreateTokenResult> {
|
|
496
|
+
const response = await fetch(url, { ...init, redirect: "error" });
|
|
497
|
+
const rawBody = await response.text();
|
|
498
|
+
let data: CreateTokenSuccess | CreateTokenError;
|
|
499
|
+
try {
|
|
500
|
+
data = JSON.parse(rawBody) as CreateTokenSuccess | CreateTokenError;
|
|
501
|
+
} catch {
|
|
502
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
503
|
+
}
|
|
504
|
+
if (data === null || typeof data !== "object") {
|
|
505
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
506
|
+
}
|
|
507
|
+
if (!response.ok && !("error" in data)) {
|
|
508
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
509
|
+
}
|
|
510
|
+
if ("error" in data && typeof data.error !== "string") {
|
|
511
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
512
|
+
}
|
|
513
|
+
if ("accessToken" in data && typeof data.accessToken !== "string") {
|
|
514
|
+
throw new Error(oidcRequestFailure(url, response));
|
|
515
|
+
}
|
|
516
|
+
return { response, status: response.status, data };
|
|
517
|
+
}
|
|
@@ -11,69 +11,190 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { isJsonObject, type JsonObject } from "./types";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
const JSON_SCHEMA_LITERAL_PAYLOAD_KEYS = new Set(["default", "const", "enum", "examples"]);
|
|
15
|
+
const JSON_SCHEMA_MAP_KEYS = new Set([
|
|
16
|
+
"properties",
|
|
17
|
+
"patternProperties",
|
|
18
|
+
"dependencies",
|
|
19
|
+
"dependentSchemas",
|
|
20
|
+
"$defs",
|
|
21
|
+
"definitions",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function setOwnKey(target: JsonObject, key: string, value: unknown): void {
|
|
25
|
+
if (key === "__proto__") {
|
|
26
|
+
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
target[key] = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type DereferenceState = {
|
|
33
|
+
unresolvedRef: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Resolve any local JSON Pointer, including escaped names and boolean schemas. */
|
|
37
|
+
function resolveLocalRef(ref: string, root: JsonObject): unknown | undefined {
|
|
38
|
+
if (!ref.startsWith("#")) return undefined;
|
|
39
|
+
let pointer: string;
|
|
40
|
+
try {
|
|
41
|
+
pointer = decodeURIComponent(ref.slice(1));
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (pointer === "") return root;
|
|
46
|
+
if (!pointer.startsWith("/")) return undefined;
|
|
22
47
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
48
|
+
let current: unknown = root;
|
|
49
|
+
for (const encodedSegment of pointer.slice(1).split("/")) {
|
|
50
|
+
const segment = encodedSegment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
51
|
+
if (Array.isArray(current)) {
|
|
52
|
+
if (!/^(0|[1-9]\d*)$/.test(segment) || !Object.hasOwn(current, segment)) return undefined;
|
|
53
|
+
current = current[Number(segment)];
|
|
54
|
+
} else if (isJsonObject(current)) {
|
|
55
|
+
if (!Object.hasOwn(current, segment)) return undefined;
|
|
56
|
+
current = current[segment];
|
|
57
|
+
} else {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
26
63
|
|
|
27
|
-
|
|
28
|
-
|
|
64
|
+
/** Find definition maps anywhere in a schema graph, excluding literal instance data. */
|
|
65
|
+
function hasDefinitionMapDeep(value: unknown, seen: Set<object>, inSchemaMap = false): boolean {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (seen.has(value)) return false;
|
|
68
|
+
seen.add(value);
|
|
69
|
+
return value.some(entry => hasDefinitionMapDeep(entry, seen, false));
|
|
70
|
+
}
|
|
71
|
+
if (!isJsonObject(value) || seen.has(value)) return false;
|
|
72
|
+
seen.add(value);
|
|
73
|
+
for (const key in value) {
|
|
74
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
75
|
+
if (!inSchemaMap && (key === "$defs" || key === "definitions")) return true;
|
|
76
|
+
if (!inSchemaMap && JSON_SCHEMA_LITERAL_PAYLOAD_KEYS.has(key)) continue;
|
|
77
|
+
const childInSchemaMap = !inSchemaMap && JSON_SCHEMA_MAP_KEYS.has(key);
|
|
78
|
+
if (hasDefinitionMapDeep(value[key], seen, childInSchemaMap)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dereferenceSchemaMap(
|
|
84
|
+
schemaMap: JsonObject,
|
|
85
|
+
root: JsonObject,
|
|
86
|
+
visitingRefs: Set<string>,
|
|
87
|
+
visitingNodes: Set<object>,
|
|
88
|
+
state: DereferenceState,
|
|
89
|
+
): JsonObject {
|
|
90
|
+
const result: JsonObject = {};
|
|
91
|
+
for (const key in schemaMap) {
|
|
92
|
+
if (!Object.hasOwn(schemaMap, key)) continue;
|
|
93
|
+
setOwnKey(result, key, dereferenceNode(schemaMap[key], root, visitingRefs, visitingNodes, state));
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
29
96
|
}
|
|
30
97
|
|
|
31
98
|
/**
|
|
32
99
|
* Recursively dereference a JSON Schema node, inlining all local `$ref` pointers.
|
|
100
|
+
* Object and array path tracking preserves the existing `{}` cycle boundary.
|
|
33
101
|
*/
|
|
34
|
-
function dereferenceNode(
|
|
35
|
-
|
|
36
|
-
|
|
102
|
+
function dereferenceNode(
|
|
103
|
+
node: unknown,
|
|
104
|
+
root: JsonObject,
|
|
105
|
+
visitingRefs: Set<string>,
|
|
106
|
+
visitingNodes: Set<object>,
|
|
107
|
+
state: DereferenceState,
|
|
108
|
+
inSchemaMap = false,
|
|
109
|
+
): unknown {
|
|
110
|
+
if (!node || typeof node !== "object") return node;
|
|
111
|
+
if (visitingNodes.has(node)) return {};
|
|
112
|
+
visitingNodes.add(node);
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(node)) {
|
|
115
|
+
const result = node.map(item => dereferenceNode(item, root, visitingRefs, visitingNodes, state));
|
|
116
|
+
visitingNodes.delete(node);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
37
119
|
|
|
38
|
-
const
|
|
120
|
+
const schemaNode = node as JsonObject;
|
|
121
|
+
const ref = Object.hasOwn(schemaNode, "$ref") ? schemaNode.$ref : undefined;
|
|
39
122
|
if (typeof ref === "string") {
|
|
40
|
-
|
|
41
|
-
|
|
123
|
+
if (visitingRefs.has(ref)) {
|
|
124
|
+
visitingNodes.delete(node);
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
42
127
|
const resolved = resolveLocalRef(ref, root);
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
128
|
+
if (resolved !== undefined) {
|
|
129
|
+
visitingRefs.add(ref);
|
|
130
|
+
const inlined = dereferenceNode(resolved, root, visitingRefs, visitingNodes, state);
|
|
131
|
+
visitingRefs.delete(ref);
|
|
47
132
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
133
|
+
// Merge sibling keywords (e.g. description, default) from the
|
|
134
|
+
// referencing node. In draft 2020-12 these are valid alongside `$ref`.
|
|
135
|
+
let hasSiblings = false;
|
|
136
|
+
for (const key in schemaNode) {
|
|
137
|
+
if (Object.hasOwn(schemaNode, key) && key !== "$ref") {
|
|
138
|
+
hasSiblings = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (inlined === false) {
|
|
143
|
+
visitingNodes.delete(node);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (!hasSiblings || (!isJsonObject(inlined) && inlined !== true)) {
|
|
147
|
+
visitingNodes.delete(node);
|
|
148
|
+
return inlined;
|
|
55
149
|
}
|
|
150
|
+
const merged: JsonObject = {};
|
|
151
|
+
if (isJsonObject(inlined)) {
|
|
152
|
+
for (const key in inlined) {
|
|
153
|
+
if (Object.hasOwn(inlined, key)) setOwnKey(merged, key, inlined[key]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const key in schemaNode) {
|
|
157
|
+
if (!Object.hasOwn(schemaNode, key) || key === "$ref") continue;
|
|
158
|
+
const siblingValue = schemaNode[key];
|
|
159
|
+
const sibling =
|
|
160
|
+
!inSchemaMap && JSON_SCHEMA_LITERAL_PAYLOAD_KEYS.has(key)
|
|
161
|
+
? schemaNode[key]
|
|
162
|
+
: !inSchemaMap && JSON_SCHEMA_MAP_KEYS.has(key) && isJsonObject(siblingValue)
|
|
163
|
+
? dereferenceSchemaMap(siblingValue, root, visitingRefs, visitingNodes, state)
|
|
164
|
+
: dereferenceNode(siblingValue, root, visitingRefs, visitingNodes, state);
|
|
165
|
+
setOwnKey(merged, key, sibling);
|
|
166
|
+
}
|
|
167
|
+
if (!state.unresolvedRef) {
|
|
168
|
+
delete merged.$defs;
|
|
169
|
+
delete merged.definitions;
|
|
170
|
+
}
|
|
171
|
+
visitingNodes.delete(node);
|
|
172
|
+
return merged;
|
|
56
173
|
}
|
|
57
|
-
|
|
58
|
-
const merged: JsonObject = { ...inlined, ...node };
|
|
59
|
-
delete merged.$ref;
|
|
60
|
-
return merged;
|
|
174
|
+
state.unresolvedRef = true;
|
|
61
175
|
}
|
|
62
176
|
|
|
63
177
|
const result: JsonObject = {};
|
|
64
|
-
for (const key in
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
result
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
result
|
|
178
|
+
for (const key in schemaNode) {
|
|
179
|
+
if (!Object.hasOwn(schemaNode, key)) continue;
|
|
180
|
+
const value = schemaNode[key];
|
|
181
|
+
// Literal instance data is not schema-shaped, even when it contains an
|
|
182
|
+
// object that happens to use schema-looking keys or a local `$ref`.
|
|
183
|
+
if (!inSchemaMap && JSON_SCHEMA_LITERAL_PAYLOAD_KEYS.has(key)) {
|
|
184
|
+
setOwnKey(result, key, value);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!inSchemaMap && JSON_SCHEMA_MAP_KEYS.has(key) && isJsonObject(value)) {
|
|
188
|
+
setOwnKey(result, key, dereferenceSchemaMap(value, root, visitingRefs, visitingNodes, state));
|
|
189
|
+
continue;
|
|
75
190
|
}
|
|
191
|
+
setOwnKey(result, key, dereferenceNode(value, root, visitingRefs, visitingNodes, state));
|
|
192
|
+
}
|
|
193
|
+
if (!state.unresolvedRef) {
|
|
194
|
+
delete result.$defs;
|
|
195
|
+
delete result.definitions;
|
|
76
196
|
}
|
|
197
|
+
visitingNodes.delete(node);
|
|
77
198
|
return result;
|
|
78
199
|
}
|
|
79
200
|
|
|
@@ -90,9 +211,8 @@ function dereferenceNode(node: unknown, root: JsonObject, visiting: Set<string>)
|
|
|
90
211
|
export function dereferenceJsonSchema(schema: unknown): unknown {
|
|
91
212
|
if (!isJsonObject(schema)) return schema;
|
|
92
213
|
|
|
93
|
-
// Fast path: nothing to dereference
|
|
94
|
-
|
|
95
|
-
if (!hasDefs) return schema;
|
|
214
|
+
// Fast path: nothing to dereference anywhere in the schema graph
|
|
215
|
+
if (!hasDefinitionMapDeep(schema, new Set())) return schema;
|
|
96
216
|
|
|
97
|
-
return dereferenceNode(schema, schema, new Set());
|
|
217
|
+
return dereferenceNode(schema, schema, new Set(), new Set(), { unresolvedRef: false });
|
|
98
218
|
}
|