@tanstack/openai-base 0.8.4 → 0.8.7
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/dist/esm/adapters/chat-completions-tool-converter.d.ts +8 -1
- package/dist/esm/adapters/chat-completions-tool-converter.js +12 -1
- package/dist/esm/adapters/chat-completions-tool-converter.js.map +1 -1
- package/dist/esm/adapters/responses-tool-converter.d.ts +8 -1
- package/dist/esm/adapters/responses-tool-converter.js +10 -1
- package/dist/esm/adapters/responses-tool-converter.js.map +1 -1
- package/dist/esm/tools/function-tool.d.ts +6 -0
- package/dist/esm/tools/function-tool.js +10 -1
- package/dist/esm/tools/function-tool.js.map +1 -1
- package/dist/esm/utils/schema-converter.d.ts +21 -0
- package/dist/esm/utils/schema-converter.js +25 -1
- package/dist/esm/utils/schema-converter.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/chat-completions-tool-converter.ts +27 -2
- package/src/adapters/responses-tool-converter.ts +25 -2
- package/src/tools/function-tool.ts +21 -1
- package/src/utils/schema-converter.ts +50 -1
|
@@ -18,7 +18,14 @@ export type ChatCompletionFunctionTool = Extract<ChatCompletionTool, {
|
|
|
18
18
|
* - Optional fields made nullable
|
|
19
19
|
* - additionalProperties: false
|
|
20
20
|
*
|
|
21
|
-
* This enables strict mode for
|
|
21
|
+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
|
|
22
|
+
*
|
|
23
|
+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
|
|
24
|
+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
|
|
25
|
+
* strict-valid shape, and `strict: true` would make the API reject the ENTIRE
|
|
26
|
+
* request with a 400. Such tools are emitted with `strict: false` (their schema
|
|
27
|
+
* passed through, only unsupported `format` keywords stripped) so they stay
|
|
28
|
+
* callable.
|
|
22
29
|
*/
|
|
23
30
|
export declare function convertFunctionToolToChatCompletionsFormat(tool: Tool, schemaConverter?: (schema: Record<string, any>, required: Array<string>) => Record<string, any>): ChatCompletionFunctionTool;
|
|
24
31
|
/**
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { makeStructuredOutputCompatible } from "../utils/schema-converter.js";
|
|
1
|
+
import { makeStructuredOutputCompatible, isStrictModeCompatible, stripUnsupportedFormats } from "../utils/schema-converter.js";
|
|
2
2
|
function convertFunctionToolToChatCompletionsFormat(tool, schemaConverter = makeStructuredOutputCompatible) {
|
|
3
3
|
const inputSchema = tool.inputSchema ?? {
|
|
4
4
|
type: "object",
|
|
5
5
|
properties: {},
|
|
6
6
|
required: []
|
|
7
7
|
};
|
|
8
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
9
|
+
return {
|
|
10
|
+
type: "function",
|
|
11
|
+
function: {
|
|
12
|
+
name: tool.name,
|
|
13
|
+
description: tool.description,
|
|
14
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
15
|
+
strict: false
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
8
19
|
const jsonSchema = {
|
|
9
20
|
...schemaConverter(inputSchema, inputSchema.required || [])
|
|
10
21
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-completions-tool-converter.js","sources":["../../../src/adapters/chat-completions-tool-converter.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"chat-completions-tool-converter.js","sources":["../../../src/adapters/chat-completions-tool-converter.ts"],"sourcesContent":["import {\n isStrictModeCompatible,\n makeStructuredOutputCompatible,\n stripUnsupportedFormats,\n} from '../utils/schema-converter'\nimport type { ChatCompletionTool } from 'openai/resources/chat/completions/completions'\nimport type { JSONSchema, Tool } from '@tanstack/ai'\n\n/**\n * Chat Completions API tool format. The SDK's `ChatCompletionTool` is the\n * union `ChatCompletionFunctionTool | ChatCompletionCustomTool`; we only\n * emit the function variant here. Re-exported as our own alias so consumers\n * importing the converter's output don't have to reach into the SDK.\n */\nexport type ChatCompletionFunctionTool = Extract<\n ChatCompletionTool,\n { type: 'function' }\n>\n\n/**\n * Converts a standard Tool to OpenAI Chat Completions ChatCompletionTool format.\n *\n * Tool schemas are already converted to JSON Schema in the ai layer.\n * We apply OpenAI-compatible transformations for strict mode:\n * - All properties in required array\n * - Optional fields made nullable\n * - additionalProperties: false\n *\n * This enables strict mode for tools whose schemas fit OpenAI's strict subset.\n *\n * Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/\n * `$defs` — common with MCP servers like Notion) can't be coerced to a\n * strict-valid shape, and `strict: true` would make the API reject the ENTIRE\n * request with a 400. Such tools are emitted with `strict: false` (their schema\n * passed through, only unsupported `format` keywords stripped) so they stay\n * callable.\n */\nexport function convertFunctionToolToChatCompletionsFormat(\n tool: Tool,\n schemaConverter: (\n schema: Record<string, any>,\n required: Array<string>,\n ) => Record<string, any> = makeStructuredOutputCompatible,\n): ChatCompletionFunctionTool {\n const inputSchema = (tool.inputSchema ?? {\n type: 'object',\n properties: {},\n required: [],\n }) as JSONSchema\n\n // Schema outside OpenAI's strict subset: send non-strict so the tool still\n // works instead of 400-ing the whole request.\n if (!isStrictModeCompatible(inputSchema)) {\n return {\n type: 'function',\n function: {\n name: tool.name,\n description: tool.description,\n parameters: stripUnsupportedFormats(inputSchema),\n strict: false,\n },\n } satisfies ChatCompletionFunctionTool\n }\n\n // Shallow-copy the converter's result before mutating: a subclass-supplied\n // schemaConverter has no contract requirement to return a fresh object,\n // and a passthrough `(s) => s` would otherwise have its caller's schema\n // mutated by the `additionalProperties = false` assignment below.\n const jsonSchema = {\n ...schemaConverter(inputSchema, inputSchema.required || []),\n }\n jsonSchema.additionalProperties = false\n\n return {\n type: 'function',\n function: {\n name: tool.name,\n description: tool.description,\n parameters: jsonSchema,\n strict: true,\n },\n } satisfies ChatCompletionFunctionTool\n}\n\n/**\n * Converts an array of standard Tools to Chat Completions format.\n * Chat Completions API primarily supports function tools.\n */\nexport function convertToolsToChatCompletionsFormat(\n tools: Array<Tool>,\n schemaConverter?: (\n schema: Record<string, any>,\n required: Array<string>,\n ) => Record<string, any>,\n): Array<ChatCompletionFunctionTool> {\n return tools.map((tool) =>\n convertFunctionToolToChatCompletionsFormat(tool, schemaConverter),\n )\n}\n"],"names":[],"mappings":";AAqCO,SAAS,2CACd,MACA,kBAG2B,gCACC;AAC5B,QAAM,cAAe,KAAK,eAAe;AAAA,IACvC,MAAM;AAAA,IACN,YAAY,CAAA;AAAA,IACZ,UAAU,CAAA;AAAA,EAAC;AAKb,MAAI,CAAC,uBAAuB,WAAW,GAAG;AACxC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,UAAU;AAAA,QACR,MAAM,KAAK;AAAA,QACX,aAAa,KAAK;AAAA,QAClB,YAAY,wBAAwB,WAAW;AAAA,QAC/C,QAAQ;AAAA,MAAA;AAAA,IACV;AAAA,EAEJ;AAMA,QAAM,aAAa;AAAA,IACjB,GAAG,gBAAgB,aAAa,YAAY,YAAY,CAAA,CAAE;AAAA,EAAA;AAE5D,aAAW,uBAAuB;AAElC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,UAAU;AAAA,MACR,MAAM,KAAK;AAAA,MACX,aAAa,KAAK;AAAA,MAClB,YAAY;AAAA,MACZ,QAAQ;AAAA,IAAA;AAAA,EACV;AAEJ;AAMO,SAAS,oCACd,OACA,iBAImC;AACnC,SAAO,MAAM;AAAA,IAAI,CAAC,SAChB,2CAA2C,MAAM,eAAe;AAAA,EAAA;AAEpE;"}
|
|
@@ -25,7 +25,14 @@ export interface ResponsesFunctionTool {
|
|
|
25
25
|
* - Optional fields made nullable
|
|
26
26
|
* - additionalProperties: false
|
|
27
27
|
*
|
|
28
|
-
* This enables strict mode for
|
|
28
|
+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
|
|
29
|
+
*
|
|
30
|
+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
|
|
31
|
+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
|
|
32
|
+
* strict-valid shape, and `strict: true` would make the Responses API reject
|
|
33
|
+
* the ENTIRE request with a 400. Such tools are emitted with `strict: false`
|
|
34
|
+
* (their schema passed through, only unsupported `format` keywords stripped) so
|
|
35
|
+
* they stay callable.
|
|
29
36
|
*/
|
|
30
37
|
export declare function convertFunctionToolToResponsesFormat(tool: Tool, schemaConverter?: (schema: Record<string, any>, required: Array<string>) => Record<string, any>): ResponsesFunctionTool;
|
|
31
38
|
/**
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { makeStructuredOutputCompatible } from "../utils/schema-converter.js";
|
|
1
|
+
import { makeStructuredOutputCompatible, isStrictModeCompatible, stripUnsupportedFormats } from "../utils/schema-converter.js";
|
|
2
2
|
function convertFunctionToolToResponsesFormat(tool, schemaConverter = makeStructuredOutputCompatible) {
|
|
3
3
|
const inputSchema = tool.inputSchema ?? {
|
|
4
4
|
type: "object",
|
|
5
5
|
properties: {},
|
|
6
6
|
required: []
|
|
7
7
|
};
|
|
8
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
9
|
+
return {
|
|
10
|
+
type: "function",
|
|
11
|
+
name: tool.name,
|
|
12
|
+
description: tool.description,
|
|
13
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
14
|
+
strict: false
|
|
15
|
+
};
|
|
16
|
+
}
|
|
8
17
|
const jsonSchema = {
|
|
9
18
|
...schemaConverter(inputSchema, inputSchema.required || [])
|
|
10
19
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"responses-tool-converter.js","sources":["../../../src/adapters/responses-tool-converter.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"responses-tool-converter.js","sources":["../../../src/adapters/responses-tool-converter.ts"],"sourcesContent":["import {\n isStrictModeCompatible,\n makeStructuredOutputCompatible,\n stripUnsupportedFormats,\n} from '../utils/schema-converter'\nimport type { JSONSchema, Tool } from '@tanstack/ai'\n\n/**\n * Responses API function tool format.\n * This is distinct from the Chat Completions API tool format.\n *\n * The Responses API uses a flatter structure:\n * { type: 'function', name: string, description?: string, parameters: object, strict?: boolean }\n *\n * vs. Chat Completions:\n * { type: 'function', function: { name, description, parameters }, strict?: boolean }\n */\nexport interface ResponsesFunctionTool {\n type: 'function'\n name: string\n description?: string | null\n parameters: Record<string, any> | null\n strict: boolean | null\n}\n\n/**\n * Converts a standard Tool to the Responses API FunctionTool format.\n *\n * Tool schemas are already converted to JSON Schema in the ai layer.\n * We apply OpenAI-compatible transformations for strict mode:\n * - All properties in required array\n * - Optional fields made nullable\n * - additionalProperties: false\n *\n * This enables strict mode for tools whose schemas fit OpenAI's strict subset.\n *\n * Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/\n * `$defs` — common with MCP servers like Notion) can't be coerced to a\n * strict-valid shape, and `strict: true` would make the Responses API reject\n * the ENTIRE request with a 400. Such tools are emitted with `strict: false`\n * (their schema passed through, only unsupported `format` keywords stripped) so\n * they stay callable.\n */\nexport function convertFunctionToolToResponsesFormat(\n tool: Tool,\n schemaConverter: (\n schema: Record<string, any>,\n required: Array<string>,\n ) => Record<string, any> = makeStructuredOutputCompatible,\n): ResponsesFunctionTool {\n const inputSchema = (tool.inputSchema ?? {\n type: 'object',\n properties: {},\n required: [],\n }) as JSONSchema\n\n // Schema outside OpenAI's strict subset: send non-strict so the tool still\n // works instead of 400-ing the whole request.\n if (!isStrictModeCompatible(inputSchema)) {\n return {\n type: 'function',\n name: tool.name,\n description: tool.description,\n parameters: stripUnsupportedFormats(inputSchema),\n strict: false,\n }\n }\n\n // Shallow-copy the converter's result before mutating — a subclass-supplied\n // schemaConverter has no contract requirement to return a fresh object;\n // mutating in place could corrupt the caller's tool definition.\n const jsonSchema = {\n ...schemaConverter(inputSchema, inputSchema.required || []),\n }\n jsonSchema.additionalProperties = false\n\n return {\n type: 'function',\n name: tool.name,\n description: tool.description,\n parameters: jsonSchema,\n strict: true,\n }\n}\n\n/**\n * Converts an array of standard Tools to Responses API format.\n * The Responses API primarily supports function tools at the base level.\n */\nexport function convertToolsToResponsesFormat(\n tools: Array<Tool>,\n schemaConverter?: (\n schema: Record<string, any>,\n required: Array<string>,\n ) => Record<string, any>,\n): Array<ResponsesFunctionTool> {\n return tools.map((tool) =>\n convertFunctionToolToResponsesFormat(tool, schemaConverter),\n )\n}\n"],"names":[],"mappings":";AA2CO,SAAS,qCACd,MACA,kBAG2B,gCACJ;AACvB,QAAM,cAAe,KAAK,eAAe;AAAA,IACvC,MAAM;AAAA,IACN,YAAY,CAAA;AAAA,IACZ,UAAU,CAAA;AAAA,EAAC;AAKb,MAAI,CAAC,uBAAuB,WAAW,GAAG;AACxC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,MAAM,KAAK;AAAA,MACX,aAAa,KAAK;AAAA,MAClB,YAAY,wBAAwB,WAAW;AAAA,MAC/C,QAAQ;AAAA,IAAA;AAAA,EAEZ;AAKA,QAAM,aAAa;AAAA,IACjB,GAAG,gBAAgB,aAAa,YAAY,YAAY,CAAA,CAAE;AAAA,EAAA;AAE5D,aAAW,uBAAuB;AAElC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAAK;AAAA,IACX,aAAa,KAAK;AAAA,IAClB,YAAY;AAAA,IACZ,QAAQ;AAAA,EAAA;AAEZ;AAMO,SAAS,8BACd,OACA,iBAI8B;AAC9B,SAAO,MAAM;AAAA,IAAI,CAAC,SAChB,qCAAqC,MAAM,eAAe;AAAA,EAAA;AAE9D;"}
|
|
@@ -13,5 +13,11 @@ export type FunctionTool = FunctionToolConfig;
|
|
|
13
13
|
* - additionalProperties: false
|
|
14
14
|
*
|
|
15
15
|
* This enables strict mode for all tools automatically.
|
|
16
|
+
*
|
|
17
|
+
* Some tool schemas (e.g. MCP server tools that use `oneOf`, `$ref`, or
|
|
18
|
+
* `$defs`) cannot be expressed under OpenAI's strict Structured Outputs
|
|
19
|
+
* subset. For those we fall back to a non-strict tool definition — stripping
|
|
20
|
+
* only the formats OpenAI rejects — so the tool is still usable instead of
|
|
21
|
+
* failing the request with a 400 "Invalid schema" error.
|
|
16
22
|
*/
|
|
17
23
|
export declare function convertFunctionToolToAdapterFormat(tool: Tool): FunctionToolConfig;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { makeStructuredOutputCompatible } from "../utils/schema-converter.js";
|
|
1
|
+
import { isStrictModeCompatible, stripUnsupportedFormats, makeStructuredOutputCompatible } from "../utils/schema-converter.js";
|
|
2
2
|
function convertFunctionToolToAdapterFormat(tool) {
|
|
3
3
|
const inputSchema = tool.inputSchema ?? {
|
|
4
4
|
type: "object",
|
|
5
5
|
properties: {},
|
|
6
6
|
required: []
|
|
7
7
|
};
|
|
8
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
9
|
+
return {
|
|
10
|
+
type: "function",
|
|
11
|
+
name: tool.name,
|
|
12
|
+
description: tool.description,
|
|
13
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
14
|
+
strict: false
|
|
15
|
+
};
|
|
16
|
+
}
|
|
8
17
|
const jsonSchema = makeStructuredOutputCompatible(
|
|
9
18
|
inputSchema,
|
|
10
19
|
inputSchema.required || []
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-tool.js","sources":["../../../src/tools/function-tool.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"function-tool.js","sources":["../../../src/tools/function-tool.ts"],"sourcesContent":["import {\n isStrictModeCompatible,\n makeStructuredOutputCompatible,\n stripUnsupportedFormats,\n} from '../utils/schema-converter'\nimport type { FunctionTool as FunctionToolConfig } from 'openai/resources/responses/responses'\nimport type { JSONSchema, Tool } from '@tanstack/ai'\n\nexport type { FunctionToolConfig }\n\n/** @deprecated Renamed to `FunctionToolConfig`. Will be removed in a future release. */\nexport type FunctionTool = FunctionToolConfig\n\n/**\n * Converts a standard Tool to OpenAI FunctionTool format.\n *\n * Tool schemas are already converted to JSON Schema in the ai layer.\n * We apply OpenAI-specific transformations for strict mode:\n * - All properties in required array\n * - Optional fields made nullable\n * - additionalProperties: false\n *\n * This enables strict mode for all tools automatically.\n *\n * Some tool schemas (e.g. MCP server tools that use `oneOf`, `$ref`, or\n * `$defs`) cannot be expressed under OpenAI's strict Structured Outputs\n * subset. For those we fall back to a non-strict tool definition — stripping\n * only the formats OpenAI rejects — so the tool is still usable instead of\n * failing the request with a 400 \"Invalid schema\" error.\n */\nexport function convertFunctionToolToAdapterFormat(\n tool: Tool,\n): FunctionToolConfig {\n const inputSchema = (tool.inputSchema ?? {\n type: 'object',\n properties: {},\n required: [],\n }) as JSONSchema\n\n if (!isStrictModeCompatible(inputSchema)) {\n return {\n type: 'function',\n name: tool.name,\n description: tool.description,\n parameters: stripUnsupportedFormats(inputSchema),\n strict: false,\n } satisfies FunctionToolConfig\n }\n\n const jsonSchema = makeStructuredOutputCompatible(\n inputSchema,\n inputSchema.required || [],\n )\n\n jsonSchema.additionalProperties = false\n\n return {\n type: 'function',\n name: tool.name,\n description: tool.description,\n parameters: jsonSchema,\n strict: true,\n } satisfies FunctionToolConfig\n}\n"],"names":[],"mappings":";AA8BO,SAAS,mCACd,MACoB;AACpB,QAAM,cAAe,KAAK,eAAe;AAAA,IACvC,MAAM;AAAA,IACN,YAAY,CAAA;AAAA,IACZ,UAAU,CAAA;AAAA,EAAC;AAGb,MAAI,CAAC,uBAAuB,WAAW,GAAG;AACxC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,MAAM,KAAK;AAAA,MACX,aAAa,KAAK;AAAA,MAClB,YAAY,wBAAwB,WAAW;AAAA,MAC/C,QAAQ;AAAA,IAAA;AAAA,EAEZ;AAEA,QAAM,aAAa;AAAA,IACjB;AAAA,IACA,YAAY,YAAY,CAAA;AAAA,EAAC;AAG3B,aAAW,uBAAuB;AAElC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAAK;AAAA,IACX,aAAa,KAAK;AAAA,IAClB,YAAY;AAAA,IACZ,QAAQ;AAAA,EAAA;AAEZ;"}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively drop JSON-Schema `format` keywords whose value isn't in OpenAI's
|
|
3
|
+
* strict-mode allowlist. Pure — returns a fresh tree and never mutates `node`,
|
|
4
|
+
* so the caller's original tool definition is left intact.
|
|
5
|
+
*
|
|
6
|
+
* A property *named* `format` always has a schema (object/boolean) value, never
|
|
7
|
+
* a bare string, so it is preserved and recursed into; only the `format`
|
|
8
|
+
* *keyword* (whose value is a string) is subject to removal.
|
|
9
|
+
*/
|
|
10
|
+
export declare function stripUnsupportedFormats(node: any): any;
|
|
1
11
|
/**
|
|
2
12
|
* Transform a JSON schema to be compatible with OpenAI's structured output requirements.
|
|
3
13
|
* OpenAI requires:
|
|
@@ -11,3 +21,14 @@
|
|
|
11
21
|
* @returns Transformed schema compatible with OpenAI structured output
|
|
12
22
|
*/
|
|
13
23
|
export declare function makeStructuredOutputCompatible(schema: Record<string, any>, originalRequired?: Array<string>): Record<string, any>;
|
|
24
|
+
/**
|
|
25
|
+
* Returns `false` when `schema` (anywhere in the tree) uses a JSON-Schema
|
|
26
|
+
* keyword outside OpenAI's strict Structured Outputs subset — i.e. it cannot be
|
|
27
|
+
* made strict-compatible and must be sent with `strict: false`.
|
|
28
|
+
*
|
|
29
|
+
* Conservative by design: keywords are matched as object keys, so a property
|
|
30
|
+
* literally named e.g. `oneOf` also trips it. That only costs that one tool its
|
|
31
|
+
* strict mode, which is strictly safer than a false "compatible" verdict that
|
|
32
|
+
* 400s the whole request.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isStrictModeCompatible(schema: unknown): boolean;
|
|
@@ -24,6 +24,28 @@ function stripUnsupportedFormats(node) {
|
|
|
24
24
|
function makeStructuredOutputCompatible(schema, originalRequired) {
|
|
25
25
|
return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired));
|
|
26
26
|
}
|
|
27
|
+
const STRICT_UNSUPPORTED_KEYWORDS = [
|
|
28
|
+
"oneOf",
|
|
29
|
+
"allOf",
|
|
30
|
+
"not",
|
|
31
|
+
"$ref",
|
|
32
|
+
"$defs",
|
|
33
|
+
"definitions"
|
|
34
|
+
];
|
|
35
|
+
function isStrictModeCompatible(schema) {
|
|
36
|
+
return !containsStrictUnsupportedKeyword(schema);
|
|
37
|
+
}
|
|
38
|
+
function containsStrictUnsupportedKeyword(node) {
|
|
39
|
+
if (Array.isArray(node)) {
|
|
40
|
+
return node.some(containsStrictUnsupportedKeyword);
|
|
41
|
+
}
|
|
42
|
+
if (node === null || typeof node !== "object") return false;
|
|
43
|
+
for (const [key, value] of Object.entries(node)) {
|
|
44
|
+
if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return true;
|
|
45
|
+
if (containsStrictUnsupportedKeyword(value)) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
27
49
|
function coerceStrictSchema(schema, originalRequired) {
|
|
28
50
|
const result = { ...schema };
|
|
29
51
|
const required = originalRequired ?? (Array.isArray(result["required"]) ? result["required"] : []);
|
|
@@ -80,6 +102,8 @@ function coerceStrictSchema(schema, originalRequired) {
|
|
|
80
102
|
return result;
|
|
81
103
|
}
|
|
82
104
|
export {
|
|
83
|
-
|
|
105
|
+
isStrictModeCompatible,
|
|
106
|
+
makeStructuredOutputCompatible,
|
|
107
|
+
stripUnsupportedFormats
|
|
84
108
|
};
|
|
85
109
|
//# sourceMappingURL=schema-converter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-converter.js","sources":["../../../src/utils/schema-converter.ts"],"sourcesContent":["/**\n * String `format` values accepted by OpenAI's strict Structured Outputs subset.\n * Any other format (e.g. \"uri\", \"uri-reference\", \"regex\") causes the API to\n * reject the whole request with `400 ... '<format>' is not a valid format`.\n * MCP servers and hand-written tools routinely declare such formats, so we strip\n * the unsupported ones before sending. See:\n * https://platform.openai.com/docs/guides/structured-outputs#supported-properties\n */\nconst SUPPORTED_STRING_FORMATS = new Set([\n 'date-time',\n 'time',\n 'date',\n 'duration',\n 'email',\n 'hostname',\n 'ipv4',\n 'ipv6',\n 'uuid',\n])\n\n/**\n * Recursively drop JSON-Schema `format` keywords whose value isn't in OpenAI's\n * strict-mode allowlist. Pure — returns a fresh tree and never mutates `node`,\n * so the caller's original tool definition is left intact.\n *\n * A property *named* `format` always has a schema (object/boolean) value, never\n * a bare string, so it is preserved and recursed into; only the `format`\n * *keyword* (whose value is a string) is subject to removal.\n */\
|
|
1
|
+
{"version":3,"file":"schema-converter.js","sources":["../../../src/utils/schema-converter.ts"],"sourcesContent":["/**\n * String `format` values accepted by OpenAI's strict Structured Outputs subset.\n * Any other format (e.g. \"uri\", \"uri-reference\", \"regex\") causes the API to\n * reject the whole request with `400 ... '<format>' is not a valid format`.\n * MCP servers and hand-written tools routinely declare such formats, so we strip\n * the unsupported ones before sending. See:\n * https://platform.openai.com/docs/guides/structured-outputs#supported-properties\n */\nconst SUPPORTED_STRING_FORMATS = new Set([\n 'date-time',\n 'time',\n 'date',\n 'duration',\n 'email',\n 'hostname',\n 'ipv4',\n 'ipv6',\n 'uuid',\n])\n\n/**\n * Recursively drop JSON-Schema `format` keywords whose value isn't in OpenAI's\n * strict-mode allowlist. Pure — returns a fresh tree and never mutates `node`,\n * so the caller's original tool definition is left intact.\n *\n * A property *named* `format` always has a schema (object/boolean) value, never\n * a bare string, so it is preserved and recursed into; only the `format`\n * *keyword* (whose value is a string) is subject to removal.\n */\nexport function stripUnsupportedFormats(node: any): any {\n if (Array.isArray(node)) return node.map(stripUnsupportedFormats)\n if (node === null || typeof node !== 'object') return node\n\n const out: Record<string, any> = {}\n for (const [key, value] of Object.entries(node)) {\n if (\n key === 'format' &&\n typeof value === 'string' &&\n !SUPPORTED_STRING_FORMATS.has(value)\n ) {\n continue\n }\n out[key] = stripUnsupportedFormats(value)\n }\n return out\n}\n\n/**\n * Transform a JSON schema to be compatible with OpenAI's structured output requirements.\n * OpenAI requires:\n * - All properties must be in the `required` array\n * - Optional fields should have null added to their type union\n * - additionalProperties must be false for objects\n * - String `format` keywords must be from a fixed allowlist (others are stripped)\n *\n * @param schema - JSON schema to transform\n * @param originalRequired - Original required array (to know which fields were optional)\n * @returns Transformed schema compatible with OpenAI structured output\n */\nexport function makeStructuredOutputCompatible(\n schema: Record<string, any>,\n originalRequired?: Array<string>,\n): Record<string, any> {\n return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))\n}\n\n/**\n * JSON-Schema keywords outside OpenAI's strict Structured Outputs subset. A\n * schema using any of these can't be coerced into a strict-valid shape, and\n * sending it with `strict: true` makes the API reject the ENTIRE request\n * (e.g. `400 Invalid schema ... 'additionalProperties' is required to be ...`).\n * Tools with such schemas are emitted with `strict: false` instead (see the\n * tool converters) so they remain callable. MCP servers (e.g. Notion) routinely\n * emit these.\n *\n * - `oneOf` / `allOf` / `not` — combinator keywords strict mode rejects\n * - `$ref` / `$defs` / `definitions` — references and definition pools whose\n * object subschemas escape the `additionalProperties: false` normalization\n * strict mode requires\n */\nconst STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [\n 'oneOf',\n 'allOf',\n 'not',\n '$ref',\n '$defs',\n 'definitions',\n]\n\n/**\n * Returns `false` when `schema` (anywhere in the tree) uses a JSON-Schema\n * keyword outside OpenAI's strict Structured Outputs subset — i.e. it cannot be\n * made strict-compatible and must be sent with `strict: false`.\n *\n * Conservative by design: keywords are matched as object keys, so a property\n * literally named e.g. `oneOf` also trips it. That only costs that one tool its\n * strict mode, which is strictly safer than a false \"compatible\" verdict that\n * 400s the whole request.\n */\nexport function isStrictModeCompatible(schema: unknown): boolean {\n return !containsStrictUnsupportedKeyword(schema)\n}\n\nfunction containsStrictUnsupportedKeyword(node: unknown): boolean {\n if (Array.isArray(node)) {\n return node.some(containsStrictUnsupportedKeyword)\n }\n if (node === null || typeof node !== 'object') return false\n for (const [key, value] of Object.entries(node)) {\n if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return true\n if (containsStrictUnsupportedKeyword(value)) return true\n }\n return false\n}\n\n/**\n * Strict-mode structural rewrite (required widening, nullability,\n * additionalProperties). Kept private so the public entry point can apply the\n * format-stripping pass exactly once over the fully-rewritten tree.\n */\nfunction coerceStrictSchema(\n schema: Record<string, any>,\n originalRequired?: Array<string>,\n): Record<string, any> {\n const result = { ...schema }\n const required =\n originalRequired ??\n (Array.isArray(result['required']) ? result['required'] : [])\n\n if (result.type === 'object' && result.properties) {\n const properties = { ...result.properties }\n const allPropertyNames = Object.keys(properties)\n\n for (const propName of allPropertyNames) {\n let prop = properties[propName]\n const wasOptional = !required.includes(propName)\n\n // Step 1: Recurse into nested structures\n if (prop.type === 'object' && prop.properties) {\n prop = coerceStrictSchema(prop, prop.required || [])\n } else if (prop.type === 'array' && prop.items) {\n prop = {\n ...prop,\n items: coerceStrictSchema(prop.items, prop.items.required || []),\n }\n } else if (prop.anyOf) {\n prop = coerceStrictSchema(prop, prop.required || [])\n } else if (prop.oneOf) {\n throw new Error(\n 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',\n )\n }\n\n // Step 2: Apply null-widening for optional properties (after recursion)\n if (wasOptional) {\n if (prop.anyOf) {\n // For anyOf, add a null variant if not already present\n if (!prop.anyOf.some((v: any) => v.type === 'null')) {\n prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] }\n }\n } else if (prop.type && !Array.isArray(prop.type)) {\n prop = { ...prop, type: [prop.type, 'null'] }\n } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {\n prop = { ...prop, type: [...prop.type, 'null'] }\n }\n }\n\n properties[propName] = prop\n }\n\n result.properties = properties\n result.required = allPropertyNames\n result.additionalProperties = false\n }\n\n if (result.type === 'array' && result.items) {\n result.items = coerceStrictSchema(result.items, result.items.required || [])\n }\n\n if (result.anyOf && Array.isArray(result.anyOf)) {\n result.anyOf = result.anyOf.map((variant) =>\n coerceStrictSchema(variant, variant.required || []),\n )\n }\n\n if (result.oneOf) {\n throw new Error(\n 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',\n )\n }\n\n return result\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/openai-base",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Shared OpenAI SDK base adapters for TanStack AI providers using Chat Completions and Responses APIs.",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"@tanstack/ai-utils": "0.2.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@tanstack/ai": "^0.
|
|
50
|
+
"@tanstack/ai": "^0.32.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@vitest/coverage-v8": "4.0.14",
|
|
54
54
|
"vite": "^7.3.3",
|
|
55
55
|
"zod": "^4.2.0",
|
|
56
|
-
"@tanstack/ai": "0.
|
|
56
|
+
"@tanstack/ai": "0.32.0"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {
|
|
59
59
|
"build": "vite build",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isStrictModeCompatible,
|
|
3
|
+
makeStructuredOutputCompatible,
|
|
4
|
+
stripUnsupportedFormats,
|
|
5
|
+
} from '../utils/schema-converter'
|
|
2
6
|
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions'
|
|
3
7
|
import type { JSONSchema, Tool } from '@tanstack/ai'
|
|
4
8
|
|
|
@@ -22,7 +26,14 @@ export type ChatCompletionFunctionTool = Extract<
|
|
|
22
26
|
* - Optional fields made nullable
|
|
23
27
|
* - additionalProperties: false
|
|
24
28
|
*
|
|
25
|
-
* This enables strict mode for
|
|
29
|
+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
|
|
30
|
+
*
|
|
31
|
+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
|
|
32
|
+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
|
|
33
|
+
* strict-valid shape, and `strict: true` would make the API reject the ENTIRE
|
|
34
|
+
* request with a 400. Such tools are emitted with `strict: false` (their schema
|
|
35
|
+
* passed through, only unsupported `format` keywords stripped) so they stay
|
|
36
|
+
* callable.
|
|
26
37
|
*/
|
|
27
38
|
export function convertFunctionToolToChatCompletionsFormat(
|
|
28
39
|
tool: Tool,
|
|
@@ -37,6 +48,20 @@ export function convertFunctionToolToChatCompletionsFormat(
|
|
|
37
48
|
required: [],
|
|
38
49
|
}) as JSONSchema
|
|
39
50
|
|
|
51
|
+
// Schema outside OpenAI's strict subset: send non-strict so the tool still
|
|
52
|
+
// works instead of 400-ing the whole request.
|
|
53
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
54
|
+
return {
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: {
|
|
57
|
+
name: tool.name,
|
|
58
|
+
description: tool.description,
|
|
59
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
60
|
+
strict: false,
|
|
61
|
+
},
|
|
62
|
+
} satisfies ChatCompletionFunctionTool
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
// Shallow-copy the converter's result before mutating: a subclass-supplied
|
|
41
66
|
// schemaConverter has no contract requirement to return a fresh object,
|
|
42
67
|
// and a passthrough `(s) => s` would otherwise have its caller's schema
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isStrictModeCompatible,
|
|
3
|
+
makeStructuredOutputCompatible,
|
|
4
|
+
stripUnsupportedFormats,
|
|
5
|
+
} from '../utils/schema-converter'
|
|
2
6
|
import type { JSONSchema, Tool } from '@tanstack/ai'
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -28,7 +32,14 @@ export interface ResponsesFunctionTool {
|
|
|
28
32
|
* - Optional fields made nullable
|
|
29
33
|
* - additionalProperties: false
|
|
30
34
|
*
|
|
31
|
-
* This enables strict mode for
|
|
35
|
+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
|
|
36
|
+
*
|
|
37
|
+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
|
|
38
|
+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
|
|
39
|
+
* strict-valid shape, and `strict: true` would make the Responses API reject
|
|
40
|
+
* the ENTIRE request with a 400. Such tools are emitted with `strict: false`
|
|
41
|
+
* (their schema passed through, only unsupported `format` keywords stripped) so
|
|
42
|
+
* they stay callable.
|
|
32
43
|
*/
|
|
33
44
|
export function convertFunctionToolToResponsesFormat(
|
|
34
45
|
tool: Tool,
|
|
@@ -43,6 +54,18 @@ export function convertFunctionToolToResponsesFormat(
|
|
|
43
54
|
required: [],
|
|
44
55
|
}) as JSONSchema
|
|
45
56
|
|
|
57
|
+
// Schema outside OpenAI's strict subset: send non-strict so the tool still
|
|
58
|
+
// works instead of 400-ing the whole request.
|
|
59
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
60
|
+
return {
|
|
61
|
+
type: 'function',
|
|
62
|
+
name: tool.name,
|
|
63
|
+
description: tool.description,
|
|
64
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
65
|
+
strict: false,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
// Shallow-copy the converter's result before mutating — a subclass-supplied
|
|
47
70
|
// schemaConverter has no contract requirement to return a fresh object;
|
|
48
71
|
// mutating in place could corrupt the caller's tool definition.
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isStrictModeCompatible,
|
|
3
|
+
makeStructuredOutputCompatible,
|
|
4
|
+
stripUnsupportedFormats,
|
|
5
|
+
} from '../utils/schema-converter'
|
|
2
6
|
import type { FunctionTool as FunctionToolConfig } from 'openai/resources/responses/responses'
|
|
3
7
|
import type { JSONSchema, Tool } from '@tanstack/ai'
|
|
4
8
|
|
|
@@ -17,6 +21,12 @@ export type FunctionTool = FunctionToolConfig
|
|
|
17
21
|
* - additionalProperties: false
|
|
18
22
|
*
|
|
19
23
|
* This enables strict mode for all tools automatically.
|
|
24
|
+
*
|
|
25
|
+
* Some tool schemas (e.g. MCP server tools that use `oneOf`, `$ref`, or
|
|
26
|
+
* `$defs`) cannot be expressed under OpenAI's strict Structured Outputs
|
|
27
|
+
* subset. For those we fall back to a non-strict tool definition — stripping
|
|
28
|
+
* only the formats OpenAI rejects — so the tool is still usable instead of
|
|
29
|
+
* failing the request with a 400 "Invalid schema" error.
|
|
20
30
|
*/
|
|
21
31
|
export function convertFunctionToolToAdapterFormat(
|
|
22
32
|
tool: Tool,
|
|
@@ -27,6 +37,16 @@ export function convertFunctionToolToAdapterFormat(
|
|
|
27
37
|
required: [],
|
|
28
38
|
}) as JSONSchema
|
|
29
39
|
|
|
40
|
+
if (!isStrictModeCompatible(inputSchema)) {
|
|
41
|
+
return {
|
|
42
|
+
type: 'function',
|
|
43
|
+
name: tool.name,
|
|
44
|
+
description: tool.description,
|
|
45
|
+
parameters: stripUnsupportedFormats(inputSchema),
|
|
46
|
+
strict: false,
|
|
47
|
+
} satisfies FunctionToolConfig
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
const jsonSchema = makeStructuredOutputCompatible(
|
|
31
51
|
inputSchema,
|
|
32
52
|
inputSchema.required || [],
|
|
@@ -27,7 +27,7 @@ const SUPPORTED_STRING_FORMATS = new Set([
|
|
|
27
27
|
* a bare string, so it is preserved and recursed into; only the `format`
|
|
28
28
|
* *keyword* (whose value is a string) is subject to removal.
|
|
29
29
|
*/
|
|
30
|
-
function stripUnsupportedFormats(node: any): any {
|
|
30
|
+
export function stripUnsupportedFormats(node: any): any {
|
|
31
31
|
if (Array.isArray(node)) return node.map(stripUnsupportedFormats)
|
|
32
32
|
if (node === null || typeof node !== 'object') return node
|
|
33
33
|
|
|
@@ -64,6 +64,55 @@ export function makeStructuredOutputCompatible(
|
|
|
64
64
|
return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* JSON-Schema keywords outside OpenAI's strict Structured Outputs subset. A
|
|
69
|
+
* schema using any of these can't be coerced into a strict-valid shape, and
|
|
70
|
+
* sending it with `strict: true` makes the API reject the ENTIRE request
|
|
71
|
+
* (e.g. `400 Invalid schema ... 'additionalProperties' is required to be ...`).
|
|
72
|
+
* Tools with such schemas are emitted with `strict: false` instead (see the
|
|
73
|
+
* tool converters) so they remain callable. MCP servers (e.g. Notion) routinely
|
|
74
|
+
* emit these.
|
|
75
|
+
*
|
|
76
|
+
* - `oneOf` / `allOf` / `not` — combinator keywords strict mode rejects
|
|
77
|
+
* - `$ref` / `$defs` / `definitions` — references and definition pools whose
|
|
78
|
+
* object subschemas escape the `additionalProperties: false` normalization
|
|
79
|
+
* strict mode requires
|
|
80
|
+
*/
|
|
81
|
+
const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
|
|
82
|
+
'oneOf',
|
|
83
|
+
'allOf',
|
|
84
|
+
'not',
|
|
85
|
+
'$ref',
|
|
86
|
+
'$defs',
|
|
87
|
+
'definitions',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns `false` when `schema` (anywhere in the tree) uses a JSON-Schema
|
|
92
|
+
* keyword outside OpenAI's strict Structured Outputs subset — i.e. it cannot be
|
|
93
|
+
* made strict-compatible and must be sent with `strict: false`.
|
|
94
|
+
*
|
|
95
|
+
* Conservative by design: keywords are matched as object keys, so a property
|
|
96
|
+
* literally named e.g. `oneOf` also trips it. That only costs that one tool its
|
|
97
|
+
* strict mode, which is strictly safer than a false "compatible" verdict that
|
|
98
|
+
* 400s the whole request.
|
|
99
|
+
*/
|
|
100
|
+
export function isStrictModeCompatible(schema: unknown): boolean {
|
|
101
|
+
return !containsStrictUnsupportedKeyword(schema)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function containsStrictUnsupportedKeyword(node: unknown): boolean {
|
|
105
|
+
if (Array.isArray(node)) {
|
|
106
|
+
return node.some(containsStrictUnsupportedKeyword)
|
|
107
|
+
}
|
|
108
|
+
if (node === null || typeof node !== 'object') return false
|
|
109
|
+
for (const [key, value] of Object.entries(node)) {
|
|
110
|
+
if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return true
|
|
111
|
+
if (containsStrictUnsupportedKeyword(value)) return true
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
/**
|
|
68
117
|
* Strict-mode structural rewrite (required widening, nullability,
|
|
69
118
|
* additionalProperties). Kept private so the public entry point can apply the
|