@oh-my-pi/pi-ai 18.11.2 → 18.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.3] - 2026-10-16
6
+
7
+ ### Fixed
8
+
9
+ - Fixed JSON Schema dereferencing rewriting literal values in `const`, `enum`, `default`, examples, and custom annotations, or treating property names as schema keywords ([#15927](https://github.com/can1357/oh-my-pi/pull/15927) by [@jchanghong023](https://github.com/jchanghong023)).
10
+ - Fixed Anthropic requests hanging forever on Bun 1.3 when the upstream accepted the request but never sent headers: aborting the request (including the first-event timeout) now fails it, so the turn retries instead of waiting until the user interrupts it ([#16010](https://github.com/can1357/oh-my-pi/pull/16010) by [@ca-io-systems](https://github.com/ca-io-systems))
11
+ - Fixed the auth gateway's `POST /v1/chat/completions` streams being dropped (`terminated`) after ~255 s without upstream output, e.g. while xAI generates a large tool call's arguments, which arrive only when complete; the stream now sends an SSE `: keepalive` comment every 15 s ([#15940](https://github.com/can1357/oh-my-pi/pull/15940) by [@radkawar](https://github.com/radkawar))
12
+ - Fixed Anthropic models with mid-conversation system messages (e.g. Claude Opus 5.5) receiving `@file` mention contents and hidden extension messages as a trailing `system` message instead of ending the request on the user's turn ([#15881](https://github.com/can1357/oh-my-pi/issues/15881))
13
+
5
14
  ## [18.11.2] - 2026-10-16
6
15
 
7
16
  ### Breaking Changes
@@ -2359,24 +2368,4 @@
2359
2368
  - Fixed Ollama chat requests sending image payloads to text-only models. Image blocks are now omitted and replaced with the standard non-vision placeholder for models without vision support, while vision-capable Ollama models continue to receive images. ([#3009](https://github.com/can1357/oh-my-pi/pull/3009) by [@serverinspector](https://github.com/serverinspector))
2360
2369
  - Fixed `SqliteAuthCredentialStore.close()` leaking one-off prepared statements created by inline `this.#db.prepare()` calls in `#authCredentialsTableExists`, `#readAuthSchemaVersion`, `#inferAuthSchemaVersion`, `#migrateAuthSchemaV0ToV1`, `#backfillCredentialIdentityKeys`, and `updateAuthCredential`. Each statement is now wrapped in `try/finally` with `stmt.finalize()`, and the `close()` method finalizes `#insertUsageCostStmt` and `#listUsageCostsStmt` which were previously missed. This caused EBUSY on Windows when tests tried to delete temp dirs containing open SQLite handles.
2361
2370
 
2362
- ## [16.1.2] - 2026-06-19
2363
-
2364
- ### Added
2365
-
2366
- - Added improved JSON repair capabilities for Anthropic tool arguments
2367
- - Added authentication broker discovery to sync credentials between local SQLite and remote state
2368
-
2369
- ### Fixed
2370
-
2371
- - Improved error feedback and transparency for malformed Anthropic tool call arguments
2372
- - Added automatic fallback for unsupported OpenAI reasoning effort levels
2373
- - Improved reliability when handling invalid reasoning parameter errors across OpenAI-compatible APIs
2374
- - Fixed OpenAI-compatible Chat Completions, Responses, and Azure Responses requests to retry once with the nearest provider-supported reasoning effort when an endpoint rejects `xhigh`/`minimal`-style effort values.
2375
-
2376
- ## [16.1.0] - 2026-06-19
2377
-
2378
- ### Added
2379
-
2380
- - Added utility functions to strip schema descriptions for optimized LLM context usage
2381
-
2382
- Older entries are archived in [packages/ai/CHANGELOG.md@aa43b0057f82](https://github.com/can1357/oh-my-pi/blob/aa43b0057f82cb167459f8e6b41c5ec9f4e0f91d/packages/ai/CHANGELOG.md).
2371
+ Older entries are archived in [packages/ai/CHANGELOG.md@0595d346917d](https://github.com/can1357/oh-my-pi/blob/0595d346917d152ea2b710332817de599f749626/packages/ai/CHANGELOG.md).
package/README.md CHANGED
@@ -84,7 +84,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
84
84
  - **vLLM** (OpenAI-compatible server; `VLLM_API_KEY` for secured deployments)
85
85
  - **GitHub Copilot** (requires OAuth, see below)
86
86
  - **Google Gemini CLI** (requires OAuth, see below)
87
- - **Antigravity** (requires OAuth, see below)
87
+ - **Google Antigravity** (requires OAuth, see below)
88
88
  - **Any OpenAI-compatible API**: LM Studio, custom proxies, etc.
89
89
 
90
90
  ## Installation
@@ -1031,7 +1031,7 @@ Several providers support OAuth authentication (some also support static API key
1031
1031
  - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
1032
1032
  - **GitHub Copilot** (Copilot subscription)
1033
1033
  - **Google Gemini CLI** (Gemini 2.0/2.5 via Google Cloud Code Assist; free tier or paid subscription)
1034
- - **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud)
1034
+ - **Google Antigravity** (Gemini, Claude, GPT-OSS via Google Cloud)
1035
1035
  - **Qwen Portal** (Qwen OAuth token or API key)
1036
1036
  - **Snowflake Cortex** (Snowflake account OAuth with PKCE and refresh, subject to the account's local-application integration)
1037
1037
 
@@ -17,7 +17,7 @@ import type { ToolResultMessage } from "../../types.js";
17
17
  * virtual registry. Re-exported here because this is where the frame builders
18
18
  * and their translation are consumed together.
19
19
  */
20
- export { cursorExecReadPath, cursorRawReadPath, omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, shellTimeoutSeconds, } from "../cursor-pi-args.js";
20
+ export { cursorExecReadPath, cursorRawReadPath, omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, shellDeadlineSeconds, shellTimeoutSeconds, } from "../cursor-pi-args.js";
21
21
  /** Flatten a tool result's content into the single `output` string the Pi frames carry. */
22
22
  export declare function piOutputText(toolResult: ToolResultMessage): string;
23
23
  /**
@@ -129,6 +129,19 @@ export declare function piTimeout(timeout: number | undefined): number | undefin
129
129
  * budgets round up: 0 seconds means "no deadline" to the bash tool.
130
130
  */
131
131
  export declare function shellTimeoutSeconds(timeoutMs: number | undefined): number | undefined;
132
+ /**
133
+ * The bash-tool kill deadline, in seconds, for a Cursor `ShellArgs`/`ShellStreamArgs` frame.
134
+ *
135
+ * Cursor's `timeout` carries the model's `block_until_ms`, which Cursor's Shell tool documents as
136
+ * how long to block before moving the command to the background, not as a kill deadline; the kill
137
+ * deadline is `hard_timeout`. Without one, a short wait (`block_until_ms: 5000` on a build) must not
138
+ * kill the command: the deadline stays the bash default (`undefined`), raised to the wait only when
139
+ * the wait is longer.
140
+ */
141
+ export declare function shellDeadlineSeconds(args: {
142
+ timeout: number;
143
+ hardTimeout?: number;
144
+ }): number | undefined;
132
145
  /**
133
146
  * Drop keys whose value is `undefined` so optional local-tool kwargs stay
134
147
  * absent rather than present-as-undefined.
@@ -267,6 +267,11 @@ export interface StreamOptions {
267
267
  frequencyPenalty?: number;
268
268
  maxTokens?: number;
269
269
  signal?: AbortSignal;
270
+ /**
271
+ * OpenAI chat-completions HTTP attempt budget. When set, transient provider
272
+ * errors are not replayed above the transport, so retries share this limit.
273
+ */
274
+ maxHttpAttempts?: number;
270
275
  apiKey?: string;
271
276
  /** @internal Stored credential row serving this request, when known. */
272
277
  credentialId?: number;
@@ -26,6 +26,8 @@ export interface OpenAIStreamRequestInit {
26
26
  body: unknown;
27
27
  signal: AbortSignal;
28
28
  fetch?: FetchImpl;
29
+ /** Override the default transport attempt budget for this request. */
30
+ maxAttempts?: number;
29
31
  /** Optional caller-specific gate composed with shared transport retry exclusions. */
30
32
  shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
31
33
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.11.2",
3
+ "version": "18.11.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.11.2",
159
- "@oh-my-pi/pi-catalog": "18.11.2",
160
- "@oh-my-pi/pi-natives": "18.11.2",
161
- "@oh-my-pi/pi-utils": "18.11.2",
162
- "@oh-my-pi/pi-wire": "18.11.2"
158
+ "@oh-my-pi/omptype": "18.11.3",
159
+ "@oh-my-pi/pi-catalog": "18.11.3",
160
+ "@oh-my-pi/pi-natives": "18.11.3",
161
+ "@oh-my-pi/pi-utils": "18.11.3",
162
+ "@oh-my-pi/pi-wire": "18.11.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -5777,9 +5777,9 @@ export function convertAnthropicMessages(
5777
5777
  compactionRequest?: AnthropicCompactionRequest;
5778
5778
  },
5779
5779
  ): AnthropicMessageParam[] {
5780
- // Indices of params emitted from `developer` messages. After the main pass,
5781
- // the ones whose placement satisfies Anthropic's mid-conversation rules are
5782
- // upgraded from the `user` role to the authoritative `system` role.
5780
+ // Indices of params emitted from agent-owned `developer` messages. After the
5781
+ // main pass, the ones whose placement satisfies Anthropic's mid-conversation
5782
+ // rules are upgraded from the `user` role to the authoritative `system` role.
5783
5783
  const developerParams: Array<{ index: number; payload?: AnthropicMessagePayload }> = [];
5784
5784
  const params: AnthropicMessageParam[] = [];
5785
5785
  // Controls rendered from request-control markers. The assistant repairs
@@ -5906,7 +5906,13 @@ export function convertAnthropicMessages(
5906
5906
  blocks.push(...anthropicToolChangeBlocks(payload.toolChanges, isOAuthToken, model));
5907
5907
  content = blocks;
5908
5908
  }
5909
- if (msg.role === "developer") developerParams.push({ index: params.length, payload });
5909
+ // User-attributed developer turns (text `@file` mentions, hidden extension
5910
+ // messages) only borrow the slot: their content is the user's, so it stays
5911
+ // on `user` instead of gaining system authority or ending the request in
5912
+ // place of the user's turn. Provider controls only exist on `system`.
5913
+ if (msg.role === "developer" && (msg.attribution !== "user" || hasProviderControls)) {
5914
+ developerParams.push({ index: params.length, payload });
5915
+ }
5910
5916
  const param: AnthropicMessageParam & ConversationalUserCarrier = { role: "user", content };
5911
5917
  // Record that this wire `user` came from a real conversational turn, so
5912
5918
  // prompt-cache decimation can tell it apart from everything else that
@@ -198,7 +198,11 @@ async function sendCoworkRequest(
198
198
  };
199
199
  const abort = (): void => {
200
200
  const reason = signal?.reason;
201
- request?.destroy(reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError"));
201
+ const error = reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError");
202
+ request?.destroy(error);
203
+ // Bun before 1.4 emits 'close' but not 'error' for destroy(error), so the
204
+ // 'error' listener below never settles a request still waiting for headers.
205
+ result.reject(error);
202
206
  };
203
207
  if (signal?.aborted) {
204
208
  release();
@@ -86,6 +86,7 @@ export {
86
86
  piReadPath,
87
87
  piReadPathHasRange,
88
88
  piTimeout,
89
+ shellDeadlineSeconds,
89
90
  shellTimeoutSeconds,
90
91
  } from "../cursor-pi-args";
91
92
 
@@ -209,6 +209,25 @@ export function shellTimeoutSeconds(timeoutMs: number | undefined): number | und
209
209
  return Math.max(1, Math.round(timeoutMs / 1000));
210
210
  }
211
211
 
212
+ /** The bash tool's deadline when its `timeout` is omitted. */
213
+ const BASH_DEFAULT_DEADLINE_SECONDS = 300;
214
+
215
+ /**
216
+ * The bash-tool kill deadline, in seconds, for a Cursor `ShellArgs`/`ShellStreamArgs` frame.
217
+ *
218
+ * Cursor's `timeout` carries the model's `block_until_ms`, which Cursor's Shell tool documents as
219
+ * how long to block before moving the command to the background, not as a kill deadline; the kill
220
+ * deadline is `hard_timeout`. Without one, a short wait (`block_until_ms: 5000` on a build) must not
221
+ * kill the command: the deadline stays the bash default (`undefined`), raised to the wait only when
222
+ * the wait is longer.
223
+ */
224
+ export function shellDeadlineSeconds(args: { timeout: number; hardTimeout?: number }): number | undefined {
225
+ const hardSeconds = shellTimeoutSeconds(args.hardTimeout);
226
+ if (hardSeconds !== undefined) return hardSeconds;
227
+ const waitSeconds = shellTimeoutSeconds(args.timeout);
228
+ return waitSeconds !== undefined && waitSeconds > BASH_DEFAULT_DEADLINE_SECONDS ? waitSeconds : undefined;
229
+ }
230
+
212
231
  /**
213
232
  * Drop keys whose value is `undefined` so optional local-tool kwargs stay
214
233
  * absent rather than present-as-undefined.
@@ -261,7 +261,7 @@ import {
261
261
  piReadDisplayPath,
262
262
  piReadPathHasRange,
263
263
  piTimeout,
264
- shellTimeoutSeconds,
264
+ shellDeadlineSeconds,
265
265
  } from "./cursor/exec-modern";
266
266
  import { handleInteractionQuery, protoUnknownFields } from "./cursor/interaction-query";
267
267
 
@@ -2681,7 +2681,7 @@ async function handleExecServerMessage(
2681
2681
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
2682
2682
  command: args.command,
2683
2683
  cwd: args.workingDirectory || undefined,
2684
- timeout: shellTimeoutSeconds(args.timeout),
2684
+ timeout: shellDeadlineSeconds(args),
2685
2685
  });
2686
2686
  const { execResult } = await resolveExecHandler(
2687
2687
  args,
@@ -2702,7 +2702,7 @@ async function handleExecServerMessage(
2702
2702
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
2703
2703
  command: args.command,
2704
2704
  cwd: args.workingDirectory || undefined,
2705
- timeout: shellTimeoutSeconds(args.timeout),
2705
+ timeout: shellDeadlineSeconds(args),
2706
2706
  });
2707
2707
  await handleShellStreamArgs(args, execMsg, runTransport, execHandlers, onToolResult);
2708
2708
  return;
@@ -3180,7 +3180,7 @@ async function handleExecServerMessage(
3180
3180
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
3181
3181
  command: args.command,
3182
3182
  cwd: args.workingDirectory || undefined,
3183
- timeout: shellTimeoutSeconds(args.timeout),
3183
+ timeout: shellDeadlineSeconds(args),
3184
3184
  });
3185
3185
  const { execResult } = await resolveExecHandler(
3186
3186
  normalizedArgs,
@@ -133,6 +133,7 @@ export function streamOpenAIAnthropicShim(
133
133
  presencePenalty: options?.presencePenalty,
134
134
  repetitionPenalty: options?.repetitionPenalty,
135
135
  maxTokens: options?.maxTokens ?? model.maxTokens ?? undefined,
136
+ maxHttpAttempts: options?.maxHttpAttempts,
136
137
  signal: options?.signal,
137
138
  headers: mergedHeaders,
138
139
  cacheRetention: config.forwardCacheOptions ? options?.cacheRetention : undefined,
@@ -531,6 +531,13 @@ function makeId(): string {
531
531
  // encodeStream (SSE)
532
532
  // ---------------------------------------------------------------------------
533
533
 
534
+ // Keepalive cadence for the SSE encoder. Some upstreams go silent for minutes
535
+ // mid-stream (e.g. xAI emits a large tool call's arguments only when complete);
536
+ // without bytes on the wire the listener's idle timeout and client watchdogs
537
+ // drop a healthy stream. SSE comment lines are ignored by spec-compliant clients.
538
+ const STREAM_KEEPALIVE_INTERVAL_MS = 15_000;
539
+ const SSE_KEEPALIVE = ": keepalive\n\n";
540
+
534
541
  export function encodeStream(
535
542
  events: AssistantMessageEventStream,
536
543
  requestedModelId: string,
@@ -546,6 +553,13 @@ export function encodeStream(
546
553
  cancelled = true;
547
554
  };
548
555
  control?.signal?.addEventListener("abort", markCancelled, { once: true });
556
+ let keepaliveTimer: NodeJS.Timeout | undefined;
557
+ const stopKeepalive = () => {
558
+ if (keepaliveTimer !== undefined) {
559
+ clearInterval(keepaliveTimer);
560
+ keepaliveTimer = undefined;
561
+ }
562
+ };
549
563
 
550
564
  const baseChunk = (delta: Record<string, unknown>, finishReason: string | null) => ({
551
565
  id,
@@ -584,6 +598,19 @@ export function encodeStream(
584
598
  let hasToolCalls = false;
585
599
  let finishReason: string = "stop";
586
600
 
601
+ keepaliveTimer = setInterval(() => {
602
+ try {
603
+ if (cancelled) {
604
+ stopKeepalive();
605
+ return;
606
+ }
607
+ controller.enqueue(encoder.encode(SSE_KEEPALIVE));
608
+ } catch {
609
+ // Controller already closed/errored (client gone); stop the timer.
610
+ stopKeepalive();
611
+ }
612
+ }, STREAM_KEEPALIVE_INTERVAL_MS);
613
+
587
614
  try {
588
615
  if (cancelled) {
589
616
  controller.close();
@@ -733,11 +760,13 @@ export function encodeStream(
733
760
  controller.close();
734
761
  }
735
762
  } finally {
763
+ stopKeepalive();
736
764
  control?.signal?.removeEventListener("abort", markCancelled);
737
765
  }
738
766
  },
739
767
  cancel(reason) {
740
768
  cancelled = true;
769
+ stopKeepalive();
741
770
  control?.signal?.removeEventListener("abort", markCancelled);
742
771
  control?.onCancel?.(reason);
743
772
  },
@@ -936,6 +936,7 @@ const streamOpenAICompletionsOnce = (
936
936
  headers: headersWithTimeout,
937
937
  body: params,
938
938
  signal: requestSignal,
939
+ maxAttempts: options?.maxHttpAttempts,
939
940
  fetch: wrapFetchForCopilotFallback(
940
941
  options?.fetch,
941
942
  model.provider === "github-copilot",
@@ -1866,7 +1867,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (mo
1866
1867
  const resolvedModel = resolveOpenAICompletionsCompat(model);
1867
1868
  return withReplaySafeStreamRetry(resolvedModel, context, options, streamOpenAICompletionsOnce, {
1868
1869
  retryEmptyCompletion: true,
1869
- retryProviderErrors: true,
1870
+ retryProviderErrors: options?.maxHttpAttempts === undefined,
1870
1871
  maxProviderErrorRetries: 1,
1871
1872
  });
1872
1873
  };
@@ -55,6 +55,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
55
55
  "repetitionPenalty",
56
56
  "stopSequences",
57
57
  "maxTokens",
58
+ "maxHttpAttempts",
58
59
  "cacheRetention",
59
60
  "cachedContent",
60
61
  "userProfileId",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
2
+ * Google Antigravity OAuth flow (Gemini, Claude, GPT-OSS via Google Cloud)
3
3
  * Uses different OAuth credentials than google-gemini-cli for access to additional models.
4
4
  */
5
5
  import { type } from "@oh-my-pi/omptype";
package/src/stream.ts CHANGED
@@ -1816,6 +1816,7 @@ function mapOptionsForApi<TApi extends Api>(
1816
1816
  repetitionPenalty: options?.repetitionPenalty,
1817
1817
  maxTokens: options?.maxTokens ?? model.maxTokens ?? undefined,
1818
1818
  signal: options?.signal,
1819
+ maxHttpAttempts: options?.maxHttpAttempts,
1819
1820
  apiKey: apiKey ?? (typeof options?.apiKey === "string" ? options.apiKey : undefined),
1820
1821
  credentialId: options?.credentialId,
1821
1822
  oauthIdentity: options?.oauthIdentity,
package/src/types.ts CHANGED
@@ -497,6 +497,11 @@ export interface StreamOptions {
497
497
  frequencyPenalty?: number;
498
498
  maxTokens?: number;
499
499
  signal?: AbortSignal;
500
+ /**
501
+ * OpenAI chat-completions HTTP attempt budget. When set, transient provider
502
+ * errors are not replayed above the transport, so retries share this limit.
503
+ */
504
+ maxHttpAttempts?: number;
500
505
  apiKey?: string;
501
506
  /** @internal Stored credential row serving this request, when known. */
502
507
  credentialId?: number;
@@ -68,6 +68,8 @@ export interface OpenAIStreamRequestInit {
68
68
  body: unknown;
69
69
  signal: AbortSignal;
70
70
  fetch?: FetchImpl;
71
+ /** Override the default transport attempt budget for this request. */
72
+ maxAttempts?: number;
71
73
  /** Optional caller-specific gate composed with shared transport retry exclusions. */
72
74
  shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
73
75
  /**
@@ -102,7 +104,7 @@ export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): P
102
104
  body: JSON.stringify(init.body),
103
105
  signal: init.signal,
104
106
  fetch: init.fetch,
105
- maxAttempts: DEFAULT_MAX_ATTEMPTS,
107
+ maxAttempts: init.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
106
108
  // A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
107
109
  // surfaces immediately instead of being slept-and-retried here; session
108
110
  // recovery owns its backoff/fallback (issue #8854).
@@ -11,6 +11,43 @@
11
11
  */
12
12
  import { isJsonObject, type JsonObject } from "./types";
13
13
 
14
+ // Only schema-valued keywords may contain references; literal JSON is opaque.
15
+ const SCHEMA_MAP_KEYS: Record<string, true> = {
16
+ properties: true,
17
+ patternProperties: true,
18
+ dependencies: true,
19
+ dependentSchemas: true,
20
+ };
21
+ const SCHEMA_ARRAY_KEYS: Record<string, true> = { anyOf: true, oneOf: true, allOf: true, prefixItems: true };
22
+ const SCHEMA_VALUE_KEYS: Record<string, true> = {
23
+ items: true,
24
+ additionalItems: true,
25
+ contains: true,
26
+ contentSchema: true,
27
+ propertyNames: true,
28
+ if: true,
29
+ // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
30
+ then: true,
31
+ else: true,
32
+ not: true,
33
+ additionalProperties: true,
34
+ unevaluatedItems: true,
35
+ unevaluatedProperties: true,
36
+ };
37
+
38
+ function dereferenceKeyword(key: string, value: unknown, root: JsonObject, visiting: Set<string>): unknown {
39
+ if (Object.hasOwn(SCHEMA_MAP_KEYS, key) && isJsonObject(value)) {
40
+ const result: JsonObject = {};
41
+ for (const name in value) result[name] = dereferenceNode(value[name], root, visiting);
42
+ return result;
43
+ }
44
+ if ((Object.hasOwn(SCHEMA_ARRAY_KEYS, key) || key === "items") && Array.isArray(value)) {
45
+ return value.map(item => dereferenceNode(item, root, visiting));
46
+ }
47
+ if (Object.hasOwn(SCHEMA_VALUE_KEYS, key)) return dereferenceNode(value, root, visiting);
48
+ return value;
49
+ }
50
+
14
51
  /**
15
52
  * Resolve a JSON-pointer-style `$ref` against the root schema's `$defs`
16
53
  * or `definitions` block. Returns `undefined` for external or unresolvable refs.
@@ -33,7 +70,6 @@ function resolveLocalRef(ref: string, root: JsonObject): JsonObject | undefined
33
70
  */
34
71
  function dereferenceNode(node: unknown, root: JsonObject, visiting: Set<string>): unknown {
35
72
  if (!isJsonObject(node)) return node;
36
- if (Array.isArray(node)) return node.map(item => dereferenceNode(item, root, visiting));
37
73
 
38
74
  const ref = node.$ref;
39
75
  if (typeof ref === "string") {
@@ -49,14 +85,17 @@ function dereferenceNode(node: unknown, root: JsonObject, visiting: Set<string>)
49
85
  // referencing node. In draft 2020-12 these are valid alongside $ref.
50
86
  let hasSiblings = false;
51
87
  for (const k in node) {
52
- if (k !== "$ref") {
88
+ if (k !== "$ref" && k !== "$defs" && k !== "definitions") {
53
89
  hasSiblings = true;
54
90
  break;
55
91
  }
56
92
  }
57
93
  if (!hasSiblings || !isJsonObject(inlined)) return inlined;
58
- const merged: JsonObject = { ...inlined, ...node };
59
- delete merged.$ref;
94
+ const merged: JsonObject = { ...inlined };
95
+ for (const key in node) {
96
+ if (key === "$ref" || key === "$defs" || key === "definitions") continue;
97
+ merged[key] = dereferenceKeyword(key, node[key], root, visiting);
98
+ }
60
99
  return merged;
61
100
  }
62
101
 
@@ -66,13 +105,7 @@ function dereferenceNode(node: unknown, root: JsonObject, visiting: Set<string>)
66
105
  // Skip $defs/definitions — they get inlined into consumers
67
106
  if (key === "$defs" || key === "definitions") continue;
68
107
 
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;
75
- }
108
+ result[key] = dereferenceKeyword(key, value, root, visiting);
76
109
  }
77
110
  return result;
78
111
  }