@gajae-code/ai 0.6.0 → 0.6.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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.1] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Generalized tool `input_schema` root-combinator flattening across providers so discriminated-union tool inputs (e.g. the `computer` tool, a `z.union`) no longer ship a bare top-level `anyOf`/`oneOf`/`allOf` root that strict validators reject. The Anthropic-only fix from 0.5.4 is now the shared, provider-agnostic `flattenToolRootCombinators` (in `utils/schema`) and is applied by Amazon Bedrock, OpenAI Chat Completions / Responses / Codex-Responses / Azure-Responses, Ollama, and Cursor. Previously only Anthropic flattened the root, so those providers forwarded the union root verbatim and a union-root tool failed upstream — Bedrock Converse (including via Kiro/CodeWhisperer relays) returned `400 TOOL_SCHEMA_INVALID: The value at toolConfig.tools.N.toolSpec.inputSchema.json.type must be one of the following: object`. Anthropic behavior is unchanged (it now calls the shared util), Google / Cloud Code Assist keep their own object-merge, and object-root tools, nested combinators, and runtime Zod validation are all untouched.
10
+
5
11
  ## [0.6.0] - 2026-06-18
6
12
  ### Fixed
7
13
 
@@ -189,10 +189,4 @@ export declare function convertAnthropicMessages(messages: Message[], model: Mod
189
189
  * object, so callers like the resolve tool keep working open-map semantics.
190
190
  */
191
191
  export declare function normalizeAnthropicToolSchema(schema: unknown): unknown;
192
- /**
193
- * Anthropic rejects tool `input_schema` roots containing top-level oneOf/anyOf/allOf.
194
- * Keep the generic normalizer schema-preserving, then flatten only provider-emitted
195
- * tool roots into one object while leaving nested combinators untouched.
196
- */
197
- export declare function normalizeAnthropicToolRootInputSchema(schema: Record<string, unknown>): Record<string, unknown>;
198
192
  export {};
@@ -7,6 +7,7 @@ export * from "./fields";
7
7
  export * from "./json-schema-validator";
8
8
  export * from "./meta-validator";
9
9
  export * from "./normalize";
10
+ export * from "./root-combinator";
10
11
  export * from "./spill";
11
12
  export * from "./types";
12
13
  export * from "./wire";
@@ -0,0 +1,12 @@
1
+ /** True when a JSON Schema node describes an object (explicit `type` or `properties`). */
2
+ export declare function isJsonSchemaObjectNode(schema: Record<string, unknown>): boolean;
3
+ /**
4
+ * Flatten a provider-emitted tool ROOT whose top level is a `oneOf`/`anyOf`/`allOf`
5
+ * combinator into one `type: "object"` schema: merge object-branch properties,
6
+ * derive the discriminant (`action`) enum, keep the common required set, and demote
7
+ * leftover combinators plus per-branch guidance into the description. Nested
8
+ * combinators (inside individual properties) are left untouched.
9
+ *
10
+ * Idempotent: a root that already lacks top-level combinators is returned unchanged.
11
+ */
12
+ export declare function flattenToolRootCombinators(schema: Record<string, unknown>): Record<string, unknown>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.6.0",
4
+ "version": "0.6.1",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@gajae-code/utils": "0.6.0",
46
+ "@gajae-code/utils": "0.6.1",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -33,7 +33,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
33
33
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
34
34
  import { parseStreamingJson } from "../utils/json-parse";
35
35
  import { resolveRetryBudget } from "../utils/retry-budget";
36
- import { toolWireSchema } from "../utils/schema/wire";
36
+ import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
37
37
  import {
38
38
  isForcedToolChoiceUnsupportedError,
39
39
  markToolChoiceIncapability,
@@ -808,7 +808,7 @@ export function convertToolConfig(
808
808
  toolSpec: {
809
809
  name: tool.name,
810
810
  description: tool.description || "",
811
- inputSchema: { json: toolWireSchema(tool) },
811
+ inputSchema: { json: flattenToolRootCombinators(toolWireSchema(tool)) },
812
812
  },
813
813
  }));
814
814
 
@@ -62,7 +62,13 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
62
62
  import { notifyProviderResponse } from "../utils/provider-response";
63
63
  import { isCopilotTransientModelError } from "../utils/retry";
64
64
  import { resolveRetryBudget } from "../utils/retry-budget";
65
- import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
65
+ import {
66
+ COMBINATOR_KEYS,
67
+ flattenToolRootCombinators,
68
+ isJsonSchemaObjectNode,
69
+ NO_STRICT,
70
+ toolWireSchema,
71
+ } from "../utils/schema";
66
72
  import { spillToDescription } from "../utils/schema/spill";
67
73
  import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
68
74
  import {
@@ -2406,22 +2412,6 @@ const MAX_ANTHROPIC_STRICT_TOOLS = 20;
2406
2412
  const MAX_ANTHROPIC_STRICT_OPTIONAL_PARAMETERS = 24;
2407
2413
  const MAX_ANTHROPIC_STRICT_UNION_PARAMETERS = 16;
2408
2414
 
2409
- /** `minItems` / `maxItems` apply to arrays; Anthropic rejects them on `type: "object"` (including `minItems: 0`/`1`). */
2410
- function isJsonSchemaArrayNode(schema: Record<string, unknown>): boolean {
2411
- const t = schema.type;
2412
- if (t === "array") return true;
2413
- if (Array.isArray(t) && t.includes("array") && !t.includes("object")) return true;
2414
- return false;
2415
- }
2416
-
2417
- function isJsonSchemaObjectNode(schema: Record<string, unknown>): boolean {
2418
- if (isJsonSchemaArrayNode(schema)) return false;
2419
- if (schema.type === "object") return true;
2420
- if (Array.isArray(schema.type) && schema.type.includes("object")) return true;
2421
- if (isRecord(schema.properties)) return true;
2422
- return false;
2423
- }
2424
-
2425
2415
  /**
2426
2416
  * Pick the principal non-null scalar type from a `type` keyword. Anthropic accepts
2427
2417
  * `type` as either a single string or an array (e.g. `["number", "null"]` for a
@@ -2573,111 +2563,6 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown {
2573
2563
  return result;
2574
2564
  }
2575
2565
 
2576
- function getRequiredNames(schema: Record<string, unknown>): Set<string> {
2577
- return new Set(
2578
- Array.isArray(schema.required)
2579
- ? schema.required.filter((entry): entry is string => typeof entry === "string")
2580
- : [],
2581
- );
2582
- }
2583
-
2584
- function getSingleLiteralValue(schema: unknown): unknown | undefined {
2585
- if (!isRecord(schema)) return undefined;
2586
- if (Object.hasOwn(schema, "const")) return schema.const;
2587
- if (Array.isArray(schema.enum) && schema.enum.length === 1) return schema.enum[0];
2588
- return undefined;
2589
- }
2590
-
2591
- function describeAnthropicRootBranch(index: number, branch: Record<string, unknown>, action: unknown): string {
2592
- const required = [...getRequiredNames(branch)];
2593
- const parts = [`Branch ${index + 1}`];
2594
- if (typeof action === "string" || typeof action === "number" || typeof action === "boolean") {
2595
- parts.push(`action ${JSON.stringify(action)}`);
2596
- }
2597
- if (required.length > 0) parts.push(`branch-required fields: ${required.join(", ")}`);
2598
- if (typeof branch.description === "string" && branch.description.length > 0) parts.push(branch.description);
2599
- return parts.join("; ");
2600
- }
2601
- function collectAnthropicRootObjectBranches(schema: unknown): Record<string, unknown>[] | undefined {
2602
- if (!isRecord(schema)) return undefined;
2603
- if (isJsonSchemaObjectNode(schema)) return [schema];
2604
-
2605
- const combinatorKeys = COMBINATOR_KEYS.filter(key => Array.isArray(schema[key]));
2606
- if (combinatorKeys.length === 0) return undefined;
2607
-
2608
- const branches: Record<string, unknown>[] = [];
2609
- for (const key of combinatorKeys) {
2610
- const variants = schema[key];
2611
- if (!Array.isArray(variants) || variants.length === 0) return undefined;
2612
- for (const variant of variants) {
2613
- const nestedBranches = collectAnthropicRootObjectBranches(variant);
2614
- if (nestedBranches === undefined) return undefined;
2615
- branches.push(...nestedBranches);
2616
- }
2617
- }
2618
- return branches;
2619
- }
2620
-
2621
- /**
2622
- * Anthropic rejects tool `input_schema` roots containing top-level oneOf/anyOf/allOf.
2623
- * Keep the generic normalizer schema-preserving, then flatten only provider-emitted
2624
- * tool roots into one object while leaving nested combinators untouched.
2625
- */
2626
- export function normalizeAnthropicToolRootInputSchema(schema: Record<string, unknown>): Record<string, unknown> {
2627
- const result: Record<string, unknown> = { ...schema };
2628
- const rootCombinators = COMBINATOR_KEYS.filter(key => Array.isArray(result[key]));
2629
- if (rootCombinators.length === 0) return result;
2630
-
2631
- const baseProperties = isRecord(result.properties) ? { ...result.properties } : {};
2632
- const flattenedBranches = rootCombinators
2633
- .map(key => ({ key, branches: collectAnthropicRootObjectBranches({ [key]: result[key] }) }))
2634
- .find(entry => entry.branches !== undefined && entry.branches.length > 0);
2635
-
2636
- result.type = "object";
2637
- result.properties = baseProperties;
2638
- result.additionalProperties = result.additionalProperties === undefined ? false : result.additionalProperties;
2639
-
2640
- if (flattenedBranches?.branches !== undefined) {
2641
- const variants = flattenedBranches.branches;
2642
- const commonRequired = variants.map(variant => getRequiredNames(variant));
2643
- const required = [...commonRequired[0]].filter(name => commonRequired.every(set => set.has(name)));
2644
- const actionValues: unknown[] = [];
2645
- const guidance: string[] = [];
2646
-
2647
- for (const [index, branch] of variants.entries()) {
2648
- if (isRecord(branch.properties)) {
2649
- Object.assign(baseProperties, branch.properties);
2650
- const actionValue = getSingleLiteralValue(branch.properties.action);
2651
- if (actionValue !== undefined && !actionValues.includes(actionValue)) actionValues.push(actionValue);
2652
- guidance.push(describeAnthropicRootBranch(index, branch, actionValue));
2653
- } else {
2654
- guidance.push(describeAnthropicRootBranch(index, branch, undefined));
2655
- }
2656
- }
2657
-
2658
- if (actionValues.length > 0) {
2659
- const existingAction = isRecord(baseProperties.action) ? { ...baseProperties.action } : {};
2660
- delete existingAction.const;
2661
- baseProperties.action = { ...existingAction, enum: actionValues };
2662
- }
2663
- result.required = required;
2664
- spillToDescription(result, [
2665
- ["rootCombinatorGuidance", guidance],
2666
- ...rootCombinators
2667
- .filter(key => key !== flattenedBranches.key)
2668
- .map(key => [key, result[key]] as [string, unknown]),
2669
- ]);
2670
- } else {
2671
- spillToDescription(
2672
- result,
2673
- rootCombinators.map(key => [key, result[key]]),
2674
- );
2675
- }
2676
-
2677
- for (const key of COMBINATOR_KEYS) delete result[key];
2678
- return result;
2679
- }
2680
-
2681
2566
  type AnthropicToolInputSchema = Anthropic.Messages.Tool["input_schema"];
2682
2567
 
2683
2568
  type AnthropicToolSchemaPlan = {
@@ -2845,7 +2730,7 @@ function normalizeAnthropicStrictSchema(
2845
2730
 
2846
2731
  function buildAnthropicBaseToolInputSchema(tool: Tool): Record<string, unknown> {
2847
2732
  const jsonSchema = toolWireSchema(tool);
2848
- return normalizeAnthropicToolRootInputSchema(
2733
+ return flattenToolRootCombinators(
2849
2734
  normalizeAnthropicToolSchema({
2850
2735
  ...jsonSchema,
2851
2736
  type: "object",
@@ -27,7 +27,7 @@ import {
27
27
  iterateWithIdleTimeout,
28
28
  } from "../utils/idle-iterator";
29
29
  import { resolveRetryBudget } from "../utils/retry-budget";
30
- import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
30
+ import { flattenToolRootCombinators, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
31
31
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
32
32
  import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
33
33
  import {
@@ -373,7 +373,7 @@ function convertTools(tools: Tool[]): OpenAITool[] {
373
373
  type: "function",
374
374
  name: tool.name,
375
375
  description: tool.description || "",
376
- parameters: sanitizeSchemaForOpenAIResponses(toolWireSchema(tool)),
376
+ parameters: sanitizeSchemaForOpenAIResponses(flattenToolRootCombinators(toolWireSchema(tool))),
377
377
  strict: false,
378
378
  }));
379
379
  }
@@ -29,7 +29,7 @@ import { normalizeSystemPrompts } from "../utils";
29
29
  import { AssistantMessageEventStream } from "../utils/event-stream";
30
30
  import { parseStreamingJson } from "../utils/json-parse";
31
31
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
32
- import { toolWireSchema } from "../utils/schema/wire";
32
+ import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
33
33
  import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
34
34
  import type { McpToolDefinition } from "./cursor/gen/agent_pb";
35
35
  import {
@@ -2183,7 +2183,7 @@ function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[]
2183
2183
  }
2184
2184
 
2185
2185
  return advertisedTools.map(tool => {
2186
- const jsonSchema = toolWireSchema(tool);
2186
+ const jsonSchema = flattenToolRootCombinators(toolWireSchema(tool));
2187
2187
  const schemaValue: JsonValue =
2188
2188
  jsonSchema && typeof jsonSchema === "object"
2189
2189
  ? (jsonSchema as JsonValue)
@@ -19,7 +19,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
19
19
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
20
20
  import { parseStreamingJson } from "../utils/json-parse";
21
21
  import { resolveRetryBudget } from "../utils/retry-budget";
22
- import { toolWireSchema } from "../utils/schema/wire";
22
+ import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
23
23
  import {
24
24
  isForcedToolChoiceUnsupportedError,
25
25
  markToolChoiceIncapability,
@@ -253,7 +253,7 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
253
253
  function: {
254
254
  name: tool.name,
255
255
  description: tool.description,
256
- parameters: toolWireSchema(tool),
256
+ parameters: flattenToolRootCombinators(toolWireSchema(tool)),
257
257
  },
258
258
  }));
259
259
  }
@@ -50,7 +50,13 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins
50
50
  import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
51
51
  import { parseStreamingJson } from "../utils/json-parse";
52
52
  import { resolveRetryBudget } from "../utils/retry-budget";
53
- import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
53
+ import {
54
+ adaptSchemaForStrict,
55
+ flattenToolRootCombinators,
56
+ NO_STRICT,
57
+ sanitizeSchemaForOpenAIResponses,
58
+ toolWireSchema,
59
+ } from "../utils/schema";
54
60
  import {
55
61
  isForcedToolChoiceUnsupportedError,
56
62
  markToolChoiceIncapability,
@@ -2658,7 +2664,7 @@ export function convertOpenAICodexResponsesTools(
2658
2664
  };
2659
2665
  }
2660
2666
  const strict = !!(!NO_STRICT && tool.strict);
2661
- const baseParameters = sanitizeSchemaForOpenAIResponses(toolWireSchema(tool));
2667
+ const baseParameters = sanitizeSchemaForOpenAIResponses(flattenToolRootCombinators(toolWireSchema(tool)));
2662
2668
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
2663
2669
  return {
2664
2670
  type: "function",
@@ -57,7 +57,7 @@ import { getKimiCommonHeaders } from "../utils/oauth/kimi";
57
57
  import { notifyProviderResponse } from "../utils/provider-response";
58
58
  import { callWithCopilotModelRetry } from "../utils/retry";
59
59
  import { resolveRetryBudget } from "../utils/retry-budget";
60
- import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
60
+ import { adaptSchemaForStrict, flattenToolRootCombinators, NO_STRICT, toolWireSchema } from "../utils/schema";
61
61
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
62
62
  import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
63
63
  import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
@@ -1832,7 +1832,7 @@ function convertTools(
1832
1832
  ): BuiltOpenAICompletionTools {
1833
1833
  const adaptedTools = tools.map(tool => {
1834
1834
  const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false;
1835
- const baseParameters = toolWireSchema(tool);
1835
+ const baseParameters = flattenToolRootCombinators(toolWireSchema(tool));
1836
1836
  const adapted = adaptSchemaForStrict(baseParameters, strict);
1837
1837
  return {
1838
1838
  tool,
@@ -50,7 +50,13 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
50
50
  import { notifyProviderResponse } from "../utils/provider-response";
51
51
  import { callWithCopilotModelRetry } from "../utils/retry";
52
52
  import { resolveRetryBudget } from "../utils/retry-budget";
53
- import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
53
+ import {
54
+ adaptSchemaForStrict,
55
+ flattenToolRootCombinators,
56
+ NO_STRICT,
57
+ sanitizeSchemaForOpenAIResponses,
58
+ toolWireSchema,
59
+ } from "../utils/schema";
54
60
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
55
61
  import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
56
62
  import {
@@ -724,7 +730,7 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o
724
730
  } as unknown as OpenAITool;
725
731
  }
726
732
  const strict = !NO_STRICT && strictMode && tool.strict !== false;
727
- const baseParameters = toolWireSchema(tool);
733
+ const baseParameters = flattenToolRootCombinators(toolWireSchema(tool));
728
734
  const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters);
729
735
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
730
736
  return {
@@ -7,6 +7,7 @@ export * from "./fields";
7
7
  export * from "./json-schema-validator";
8
8
  export * from "./meta-validator";
9
9
  export * from "./normalize";
10
+ export * from "./root-combinator";
10
11
  export * from "./spill";
11
12
  export * from "./types";
12
13
  export * from "./wire";
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Tool `input_schema` root flattening.
3
+ *
4
+ * Tool/function-calling `input_schema` roots MUST be a single JSON Schema object.
5
+ * Bedrock Converse (`toolConfig.tools[*].toolSpec.inputSchema.json.type must be
6
+ * one of the following: object`), OpenAI strict mode, Gemini, and Anthropic all
7
+ * reject roots whose top level is a bare `oneOf`/`anyOf`/`allOf` combinator (the
8
+ * validators require `type: "object"`).
9
+ *
10
+ * Zod `z.union(...)` / `z.discriminatedUnion(...)` tool parameters serialize to
11
+ * exactly such a combinator root, so this normalization is provider-agnostic and
12
+ * runs inside the shared wire pipeline (`toolWireSchema`).
13
+ */
14
+ import { isRecord } from "@gajae-code/utils";
15
+ import { COMBINATOR_KEYS } from "./fields";
16
+ import { spillToDescription } from "./spill";
17
+
18
+ /** `minItems` / `maxItems` apply to arrays; some validators reject them on `type: "object"`. */
19
+ function isJsonSchemaArrayNode(schema: Record<string, unknown>): boolean {
20
+ const t = schema.type;
21
+ if (t === "array") return true;
22
+ if (Array.isArray(t) && t.includes("array") && !t.includes("object")) return true;
23
+ return false;
24
+ }
25
+
26
+ /** True when a JSON Schema node describes an object (explicit `type` or `properties`). */
27
+ export function isJsonSchemaObjectNode(schema: Record<string, unknown>): boolean {
28
+ if (isJsonSchemaArrayNode(schema)) return false;
29
+ if (schema.type === "object") return true;
30
+ if (Array.isArray(schema.type) && schema.type.includes("object")) return true;
31
+ if (isRecord(schema.properties)) return true;
32
+ return false;
33
+ }
34
+
35
+ function getRequiredNames(schema: Record<string, unknown>): Set<string> {
36
+ return new Set(
37
+ Array.isArray(schema.required)
38
+ ? schema.required.filter((entry): entry is string => typeof entry === "string")
39
+ : [],
40
+ );
41
+ }
42
+
43
+ function getSingleLiteralValue(schema: unknown): unknown | undefined {
44
+ if (!isRecord(schema)) return undefined;
45
+ if (Object.hasOwn(schema, "const")) return schema.const;
46
+ if (Array.isArray(schema.enum) && schema.enum.length === 1) return schema.enum[0];
47
+ return undefined;
48
+ }
49
+
50
+ function describeRootBranch(index: number, branch: Record<string, unknown>, action: unknown): string {
51
+ const required = [...getRequiredNames(branch)];
52
+ const parts = [`Branch ${index + 1}`];
53
+ if (typeof action === "string" || typeof action === "number" || typeof action === "boolean") {
54
+ parts.push(`action ${JSON.stringify(action)}`);
55
+ }
56
+ if (required.length > 0) parts.push(`branch-required fields: ${required.join(", ")}`);
57
+ if (typeof branch.description === "string" && branch.description.length > 0) parts.push(branch.description);
58
+ return parts.join("; ");
59
+ }
60
+
61
+ function collectRootObjectBranches(schema: unknown): Record<string, unknown>[] | undefined {
62
+ if (!isRecord(schema)) return undefined;
63
+ if (isJsonSchemaObjectNode(schema)) return [schema];
64
+
65
+ const combinatorKeys = COMBINATOR_KEYS.filter(key => Array.isArray(schema[key]));
66
+ if (combinatorKeys.length === 0) return undefined;
67
+
68
+ const branches: Record<string, unknown>[] = [];
69
+ for (const key of combinatorKeys) {
70
+ const variants = schema[key];
71
+ if (!Array.isArray(variants) || variants.length === 0) return undefined;
72
+ for (const variant of variants) {
73
+ const nestedBranches = collectRootObjectBranches(variant);
74
+ if (nestedBranches === undefined) return undefined;
75
+ branches.push(...nestedBranches);
76
+ }
77
+ }
78
+ return branches;
79
+ }
80
+
81
+ /**
82
+ * Flatten a provider-emitted tool ROOT whose top level is a `oneOf`/`anyOf`/`allOf`
83
+ * combinator into one `type: "object"` schema: merge object-branch properties,
84
+ * derive the discriminant (`action`) enum, keep the common required set, and demote
85
+ * leftover combinators plus per-branch guidance into the description. Nested
86
+ * combinators (inside individual properties) are left untouched.
87
+ *
88
+ * Idempotent: a root that already lacks top-level combinators is returned unchanged.
89
+ */
90
+ export function flattenToolRootCombinators(schema: Record<string, unknown>): Record<string, unknown> {
91
+ const rootCombinators = COMBINATOR_KEYS.filter(key => Array.isArray(schema[key]));
92
+ if (rootCombinators.length === 0) return schema;
93
+ const result: Record<string, unknown> = { ...schema };
94
+
95
+ const baseProperties = isRecord(result.properties) ? { ...result.properties } : {};
96
+ const flattenedBranches = rootCombinators
97
+ .map(key => ({ key, branches: collectRootObjectBranches({ [key]: result[key] }) }))
98
+ .find(entry => entry.branches !== undefined && entry.branches.length > 0);
99
+
100
+ result.type = "object";
101
+ result.properties = baseProperties;
102
+ result.additionalProperties = result.additionalProperties === undefined ? false : result.additionalProperties;
103
+
104
+ if (flattenedBranches?.branches !== undefined) {
105
+ const variants = flattenedBranches.branches;
106
+ const commonRequired = variants.map(variant => getRequiredNames(variant));
107
+ const required = [...commonRequired[0]].filter(name => commonRequired.every(set => set.has(name)));
108
+ const actionValues: unknown[] = [];
109
+ const guidance: string[] = [];
110
+
111
+ for (const [index, branch] of variants.entries()) {
112
+ if (isRecord(branch.properties)) {
113
+ Object.assign(baseProperties, branch.properties);
114
+ const actionValue = getSingleLiteralValue(branch.properties.action);
115
+ if (actionValue !== undefined && !actionValues.includes(actionValue)) actionValues.push(actionValue);
116
+ guidance.push(describeRootBranch(index, branch, actionValue));
117
+ } else {
118
+ guidance.push(describeRootBranch(index, branch, undefined));
119
+ }
120
+ }
121
+
122
+ if (actionValues.length > 0) {
123
+ const existingAction = isRecord(baseProperties.action) ? { ...baseProperties.action } : {};
124
+ delete existingAction.const;
125
+ baseProperties.action = { ...existingAction, enum: actionValues };
126
+ }
127
+ result.required = required;
128
+ spillToDescription(result, [
129
+ ["rootCombinatorGuidance", guidance],
130
+ ...rootCombinators
131
+ .filter(key => key !== flattenedBranches.key)
132
+ .map(key => [key, result[key]] as [string, unknown]),
133
+ ]);
134
+ } else {
135
+ spillToDescription(
136
+ result,
137
+ rootCombinators.map(key => [key, result[key]]),
138
+ );
139
+ }
140
+
141
+ for (const key of COMBINATOR_KEYS) delete result[key];
142
+ return result;
143
+ }