@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/types/adapter-internals/aws-region.d.ts +7 -0
  3. package/dist/types/core.d.ts +1 -0
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  6. package/dist/types/providers/anthropic.d.ts +1 -1
  7. package/dist/types/providers/cursor.d.ts +27 -1
  8. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  9. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  10. package/dist/types/utils/discovery/openai-compatible.d.ts +21 -0
  11. package/dist/types/utils/h2-fetch.d.ts +7 -0
  12. package/dist/types/utils/schema/normalize.d.ts +0 -5
  13. package/dist/types/utils/sqlite-errors.d.ts +4 -0
  14. package/package.json +3 -3
  15. package/src/adapter-internals/aws-region.d.ts +7 -0
  16. package/src/adapter-internals/aws-region.ts +14 -0
  17. package/src/auth-broker/server.ts +10 -1
  18. package/src/auth-storage.ts +14 -14
  19. package/src/core.ts +1 -0
  20. package/src/index.ts +1 -0
  21. package/src/model-thinking.ts +8 -0
  22. package/src/models.json +201 -3
  23. package/src/provider-models/openai-compat.ts +93 -8
  24. package/src/providers/amazon-bedrock.ts +5 -1
  25. package/src/providers/anthropic.d.ts +1 -1
  26. package/src/providers/anthropic.ts +1 -1
  27. package/src/providers/aws-credentials.ts +6 -0
  28. package/src/providers/cursor.d.ts +27 -1
  29. package/src/providers/cursor.ts +234 -17
  30. package/src/providers/google-gemini-headers.d.ts +1 -1
  31. package/src/providers/google-gemini-headers.ts +1 -1
  32. package/src/providers/kiro-api-key.ts +33 -8
  33. package/src/providers/kiro-codewhisperer.ts +4 -1
  34. package/src/providers/openai-codex-responses.d.ts +6 -0
  35. package/src/providers/openai-codex-responses.ts +17 -2
  36. package/src/providers/pi-native-client.ts +24 -1
  37. package/src/utils/discovery/antigravity.ts +10 -1
  38. package/src/utils/discovery/openai-compatible.ts +38 -0
  39. package/src/utils/h2-fetch.ts +10 -0
  40. package/src/utils/oauth/callback-server.ts +8 -1
  41. package/src/utils/oauth/glm-zcode.ts +1 -1
  42. package/src/utils/oauth/kiro.ts +91 -22
  43. package/src/utils/schema/dereference.ts +169 -49
  44. package/src/utils/schema/draft.ts +46 -23
  45. package/src/utils/schema/normalize.d.ts +0 -5
  46. package/src/utils/schema/normalize.ts +396 -119
  47. package/src/utils/schema/types.ts +3 -1
  48. package/src/utils/schema/zod-decontaminate.ts +83 -29
  49. package/src/utils/sqlite-errors.d.ts +4 -0
  50. package/src/utils/sqlite-errors.ts +13 -0
  51. 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
  *
@@ -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__", JSON.stringify(resultState)),
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
 
@@ -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 response = await fetchOidc(url, {
231
- method: "POST",
232
- headers: { "Content-Type": "application/json" },
233
- body: JSON.stringify(body),
234
- signal,
235
- });
236
-
237
- const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
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 ("accessToken" in data && typeof data.accessToken === "string") {
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 = Math.ceil(currentInterval * 1.5);
288
+ currentInterval += 5_000;
248
289
  continue;
249
290
  }
250
291
  if (SSO_OIDC_FATAL_ERRORS.has(errorCode)) {
251
- const desc = data.error_description ? `: ${data.error_description}` : "";
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
- const desc = data.error_description ? `: ${data.error_description}` : "";
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
- let errorBody = "";
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
- * Resolve a JSON-pointer-style `$ref` against the root schema's `$defs`
16
- * or `definitions` block. Returns `undefined` for external or unresolvable refs.
17
- */
18
- function resolveLocalRef(ref: string, root: JsonObject): JsonObject | undefined {
19
- // Only handle local refs: #/$defs/Name or #/definitions/Name
20
- const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
21
- if (!match) return undefined;
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
- const [, defsKey, name] = match;
24
- const defs = root[defsKey!];
25
- if (!isJsonObject(defs)) return undefined;
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
- const resolved = defs[name!];
28
- return isJsonObject(resolved) ? resolved : undefined;
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(node: unknown, root: JsonObject, visiting: Set<string>): unknown {
35
- if (!isJsonObject(node)) return node;
36
- if (Array.isArray(node)) return node.map(item => dereferenceNode(item, root, visiting));
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 ref = node.$ref;
120
+ const schemaNode = node as JsonObject;
121
+ const ref = Object.hasOwn(schemaNode, "$ref") ? schemaNode.$ref : undefined;
39
122
  if (typeof ref === "string") {
40
- // Break circular references
41
- if (visiting.has(ref)) return {};
123
+ if (visitingRefs.has(ref)) {
124
+ visitingNodes.delete(node);
125
+ return {};
126
+ }
42
127
  const resolved = resolveLocalRef(ref, root);
43
- if (!resolved) return node; // External ref — leave as-is
44
- visiting.add(ref);
45
- const inlined = dereferenceNode(resolved, root, visiting);
46
- visiting.delete(ref);
128
+ if (resolved !== undefined) {
129
+ visitingRefs.add(ref);
130
+ const inlined = dereferenceNode(resolved, root, visitingRefs, visitingNodes, state);
131
+ visitingRefs.delete(ref);
47
132
 
48
- // Merge sibling keywords (e.g. description, default) from the
49
- // referencing node. In draft 2020-12 these are valid alongside $ref.
50
- let hasSiblings = false;
51
- for (const k in node) {
52
- if (k !== "$ref") {
53
- hasSiblings = true;
54
- break;
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
- if (!hasSiblings || !isJsonObject(inlined)) return inlined;
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 node) {
65
- const value = node[key];
66
- // Skip $defs/definitions — they get inlined into consumers
67
- if (key === "$defs" || key === "definitions") continue;
68
-
69
- if (Array.isArray(value)) {
70
- result[key] = value.map(item => dereferenceNode(item, root, visiting));
71
- } else if (isJsonObject(value)) {
72
- result[key] = dereferenceNode(value, root, visiting);
73
- } else {
74
- result[key] = value;
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
- const hasDefs = schema.$defs !== undefined || schema.definitions !== undefined;
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
  }