@gajae-code/ai 0.15.5 → 0.16.0

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 (88) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/types/adapter-internals/aws-region.d.ts +7 -0
  3. package/dist/types/auth-broker/client.d.ts +6 -2
  4. package/dist/types/auth-broker/remote-store.d.ts +14 -2
  5. package/dist/types/auth-broker/types.d.ts +6 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
  7. package/dist/types/auth-gateway/server.d.ts +39 -5
  8. package/dist/types/auth-gateway/types.d.ts +16 -2
  9. package/dist/types/auth-storage.d.ts +92 -24
  10. package/dist/types/core.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  13. package/dist/types/providers/anthropic.d.ts +1 -1
  14. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  15. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  16. package/dist/types/providers/register-builtins.d.ts +12 -12
  17. package/dist/types/stream.d.ts +2 -1
  18. package/dist/types/types.d.ts +22 -2
  19. package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
  20. package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
  21. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  22. package/dist/types/utils/oauth/types.d.ts +1 -1
  23. package/dist/types/utils/retry.d.ts +2 -0
  24. package/dist/types/utils/schema/normalize.d.ts +0 -5
  25. package/dist/types/utils/sqlite-errors.d.ts +4 -0
  26. package/package.json +3 -3
  27. package/src/adapter-internals/aws-region.d.ts +7 -0
  28. package/src/adapter-internals/aws-region.ts +14 -0
  29. package/src/auth-broker/client.ts +41 -13
  30. package/src/auth-broker/redact.ts +25 -1
  31. package/src/auth-broker/remote-store.ts +374 -115
  32. package/src/auth-broker/server.ts +131 -91
  33. package/src/auth-broker/types.ts +6 -0
  34. package/src/auth-broker/wire-schemas.ts +6 -0
  35. package/src/auth-gateway/server.ts +447 -79
  36. package/src/auth-gateway/types.ts +28 -2
  37. package/src/auth-storage.ts +672 -168
  38. package/src/cli.ts +1 -0
  39. package/src/core.ts +1 -0
  40. package/src/index.ts +1 -0
  41. package/src/model-thinking.ts +8 -0
  42. package/src/models.json +1224 -3
  43. package/src/provider-models/descriptors.ts +3 -1
  44. package/src/provider-models/openai-compat.ts +102 -1
  45. package/src/providers/amazon-bedrock.ts +5 -1
  46. package/src/providers/anthropic.d.ts +1 -1
  47. package/src/providers/anthropic.ts +8 -2
  48. package/src/providers/aws-credentials.ts +6 -0
  49. package/src/providers/azure-openai-responses.ts +4 -1
  50. package/src/providers/cursor.ts +256 -101
  51. package/src/providers/gitlab-duo.ts +18 -1
  52. package/src/providers/google-gemini-cli.ts +3 -0
  53. package/src/providers/google-gemini-headers.d.ts +1 -1
  54. package/src/providers/google-gemini-headers.ts +1 -1
  55. package/src/providers/google-shared.ts +3 -0
  56. package/src/providers/kiro-api-key.ts +33 -8
  57. package/src/providers/kiro-codewhisperer.ts +28 -9
  58. package/src/providers/ollama.ts +3 -0
  59. package/src/providers/openai-codex-responses.d.ts +6 -0
  60. package/src/providers/openai-codex-responses.ts +37 -8
  61. package/src/providers/openai-completions.ts +11 -1
  62. package/src/providers/openai-responses.ts +10 -1
  63. package/src/providers/pi-native-client.ts +25 -1
  64. package/src/providers/pi-native-server.ts +24 -0
  65. package/src/providers/register-builtins.d.ts +12 -12
  66. package/src/providers/register-builtins.ts +16 -3
  67. package/src/stream.d.ts +2 -1
  68. package/src/stream.ts +175 -67
  69. package/src/types.d.ts +22 -2
  70. package/src/types.ts +27 -1
  71. package/src/utils/oauth/api-key-login.ts +13 -2
  72. package/src/utils/oauth/api-key-validation.ts +242 -41
  73. package/src/utils/oauth/commandcode.ts +17 -0
  74. package/src/utils/oauth/index.ts +20 -5
  75. package/src/utils/oauth/kiro.ts +91 -22
  76. package/src/utils/oauth/types.d.ts +1 -1
  77. package/src/utils/oauth/types.ts +1 -0
  78. package/src/utils/retry.d.ts +2 -0
  79. package/src/utils/retry.ts +15 -2
  80. package/src/utils/schema/dereference.ts +169 -49
  81. package/src/utils/schema/draft.ts +46 -23
  82. package/src/utils/schema/normalize.d.ts +0 -5
  83. package/src/utils/schema/normalize.ts +396 -119
  84. package/src/utils/schema/types.ts +3 -1
  85. package/src/utils/schema/zod-decontaminate.ts +83 -29
  86. package/src/utils/sqlite-errors.d.ts +4 -0
  87. package/src/utils/sqlite-errors.ts +13 -0
  88. package/src/utils/tool-choice-capability.ts +2 -3
@@ -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
+ }
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "kiro" | "alibaba-token-plan" | "anthropic" | "bizrouter" | "mara" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "opengateway" | "openrouter" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "sglang" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "opencodex" | "zai";
10
+ export type OAuthProvider = "kiro" | "alibaba-token-plan" | "anthropic" | "bizrouter" | "mara" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "commandcode-goat" | "opengateway" | "openrouter" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "sglang" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "opencodex" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
@@ -43,6 +43,7 @@ export type OAuthProvider =
43
43
  | "openai-codex-device"
44
44
  | "opencode-go"
45
45
  | "opencode-zen"
46
+ | "commandcode-goat"
46
47
  | "opengateway"
47
48
  | "openrouter"
48
49
  | "parallel"
@@ -24,4 +24,6 @@ export declare function callWithCopilotModelRetry<T>(fn: () => Promise<T>, optio
24
24
  signal?: AbortSignal;
25
25
  retryBaseDelayMs?: number;
26
26
  fallbackManaged?: boolean;
27
+ requestMaxRetries?: number;
28
+ disableProviderRetries?: boolean;
27
29
  }): Promise<T>;
@@ -34,9 +34,22 @@ const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
34
34
  */
35
35
  export async function callWithCopilotModelRetry<T>(
36
36
  fn: () => Promise<T>,
37
- options: { provider: string; signal?: AbortSignal; retryBaseDelayMs?: number; fallbackManaged?: boolean },
37
+ options: {
38
+ provider: string;
39
+ signal?: AbortSignal;
40
+ retryBaseDelayMs?: number;
41
+ fallbackManaged?: boolean;
42
+ requestMaxRetries?: number;
43
+ disableProviderRetries?: boolean;
44
+ },
38
45
  ): Promise<T> {
39
- if (options.provider !== "github-copilot" || options.fallbackManaged) return fn();
46
+ if (
47
+ options.provider !== "github-copilot" ||
48
+ options.fallbackManaged ||
49
+ options.requestMaxRetries === 0 ||
50
+ options.disableProviderRetries
51
+ )
52
+ return fn();
40
53
 
41
54
  let lastError: unknown;
42
55
  const retryBaseDelayMs = options.retryBaseDelayMs ?? COPILOT_MODEL_RETRY_BASE_DELAY_MS;
@@ -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
  }