Skip to content

Commit 134343e

Browse files
committed
feat(tools): implement comprehensive schema normalization
Adds anyOf flattening, nullable type normalization, and strict keyword filtering to tool definitions. Enhances backend compatibility with strict models (Claude/Gemini) by porting logic from opencode-antigravity-auth.
1 parent 5260fe7 commit 134343e

3 files changed

Lines changed: 147 additions & 13 deletions

File tree

lib/request/helpers/tool-utils.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ export interface Tool {
1717
/**
1818
* Cleans up tool definitions to ensure strict JSON Schema compliance.
1919
*
20-
* Implements "require" logic:
20+
* Implements "require" logic and advanced normalization:
2121
* 1. Filters 'required' array to remove properties that don't exist in 'properties'.
22-
* (Fixes "property is not defined" errors from strict validators)
23-
* 2. Injects a placeholder property for empty parameter objects if needed.
24-
* 3. Handles nested object schemas recursively.
22+
* 2. Injects a placeholder property for empty parameter objects.
23+
* 3. Flattens 'anyOf' with 'const' values into 'enum'.
24+
* 4. Normalizes nullable types (array types) to single type + description.
25+
* 5. Removes unsupported keywords (additionalProperties, const, etc.).
2526
*
2627
* @param tools - Array of tool definitions
2728
* @returns Cleaned array of tool definitions
@@ -50,7 +51,45 @@ export function cleanupToolDefinitions(tools: unknown): unknown {
5051
function cleanupSchema(schema: Record<string, unknown>): void {
5152
if (!schema || typeof schema !== "object") return;
5253

53-
// 1. Filter 'required' array
54+
// 1. Flatten Unions (anyOf -> enum)
55+
if (Array.isArray(schema.anyOf)) {
56+
const anyOf = schema.anyOf as Record<string, unknown>[];
57+
const allConst = anyOf.every((opt) => "const" in opt);
58+
if (allConst && anyOf.length > 0) {
59+
const enumValues = anyOf.map((opt) => opt.const);
60+
schema.enum = enumValues;
61+
delete schema.anyOf;
62+
63+
// Infer type from first value if missing
64+
if (!schema.type) {
65+
const firstVal = enumValues[0];
66+
if (typeof firstVal === "string") schema.type = "string";
67+
else if (typeof firstVal === "number") schema.type = "number";
68+
else if (typeof firstVal === "boolean") schema.type = "boolean";
69+
}
70+
}
71+
}
72+
73+
// 2. Flatten Nullable Types (["string", "null"] -> "string")
74+
if (Array.isArray(schema.type)) {
75+
const types = schema.type as string[];
76+
const isNullable = types.includes("null");
77+
const nonNullTypes = types.filter((t) => t !== "null");
78+
79+
if (nonNullTypes.length > 0) {
80+
// Use the first non-null type (most strict models expect a single string type)
81+
schema.type = nonNullTypes[0];
82+
if (isNullable) {
83+
const desc = (schema.description as string) || "";
84+
// Only append if not already present
85+
if (!desc.toLowerCase().includes("nullable")) {
86+
schema.description = desc ? `${desc} (nullable)` : "(nullable)";
87+
}
88+
}
89+
}
90+
}
91+
92+
// 3. Filter 'required' array
5493
if (
5594
Array.isArray(schema.required) &&
5695
schema.properties &&
@@ -70,9 +109,7 @@ function cleanupSchema(schema: Record<string, unknown>): void {
70109
}
71110
}
72111

73-
// 2. Handle empty object parameters (Claude/Gemini compatibility)
74-
// If properties is empty but type is object, some models fail.
75-
// We inject a placeholder to make it a valid non-empty object.
112+
// 4. Handle empty object parameters
76113
if (
77114
schema.type === "object" &&
78115
(!schema.properties || Object.keys(schema.properties as object).length === 0)
@@ -83,19 +120,23 @@ function cleanupSchema(schema: Record<string, unknown>): void {
83120
description: "This property is a placeholder and should be ignored.",
84121
},
85122
};
86-
// Ideally we shouldn't make it required unless necessary, but some validators want it.
87-
// For now, we'll leave it optional to avoid forcing the model to generate it.
88123
}
89124

90-
// 3. Recurse into properties
125+
// 5. Remove unsupported keywords
126+
delete schema.additionalProperties;
127+
delete schema.const;
128+
delete schema.title;
129+
delete schema.$schema;
130+
131+
// 6. Recurse into properties
91132
if (schema.properties && typeof schema.properties === "object") {
92133
const props = schema.properties as Record<string, Record<string, unknown>>;
93134
for (const key in props) {
94135
cleanupSchema(props[key]);
95136
}
96137
}
97138

98-
// 4. Recurse into array items
139+
// 7. Recurse into array items
99140
if (schema.items && typeof schema.items === "object") {
100141
cleanupSchema(schema.items as Record<string, unknown>);
101142
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "oc-chatgpt-multi-auth",
3-
"version": "4.9.4",
3+
"version": "4.9.5",
44
"description": "Multi-account rotation plugin for ChatGPT Plus/Pro (OAuth / Codex backend)",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

test/request-transformer.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,99 @@ describe('Request Transformer Module', () => {
15561556
const tool = (result.tools as any)[0];
15571557
expect(tool.function.parameters.properties).toHaveProperty('_placeholder');
15581558
});
1559+
1560+
it('should flatten anyOf with const values to enum', async () => {
1561+
const body: RequestBody = {
1562+
model: 'gpt-5-codex',
1563+
input: [],
1564+
tools: [
1565+
{
1566+
type: 'function',
1567+
function: {
1568+
name: 'enum_tool',
1569+
parameters: {
1570+
type: 'object',
1571+
properties: {
1572+
choice: {
1573+
anyOf: [
1574+
{ const: 'A' },
1575+
{ const: 'B' }
1576+
]
1577+
}
1578+
}
1579+
}
1580+
}
1581+
}
1582+
]
1583+
};
1584+
1585+
const result = await transformRequestBody(body, codexInstructions);
1586+
const tool = (result.tools as any)[0];
1587+
const prop = tool.function.parameters.properties.choice;
1588+
expect(prop.anyOf).toBeUndefined();
1589+
expect(prop.enum).toEqual(['A', 'B']);
1590+
expect(prop.type).toBe('string');
1591+
});
1592+
1593+
it('should normalize nullable array types to single type + description', async () => {
1594+
const body: RequestBody = {
1595+
model: 'gpt-5-codex',
1596+
input: [],
1597+
tools: [
1598+
{
1599+
type: 'function',
1600+
function: {
1601+
name: 'null_tool',
1602+
parameters: {
1603+
type: 'object',
1604+
properties: {
1605+
optional_str: {
1606+
type: ['string', 'null'],
1607+
description: 'An optional string'
1608+
}
1609+
}
1610+
}
1611+
}
1612+
}
1613+
]
1614+
};
1615+
1616+
const result = await transformRequestBody(body, codexInstructions);
1617+
const tool = (result.tools as any)[0];
1618+
const prop = tool.function.parameters.properties.optional_str;
1619+
expect(prop.type).toBe('string');
1620+
expect(prop.description).toBe('An optional string (nullable)');
1621+
});
1622+
1623+
it('should remove unsupported keywords', async () => {
1624+
const body: RequestBody = {
1625+
model: 'gpt-5-codex',
1626+
input: [],
1627+
tools: [
1628+
{
1629+
type: 'function',
1630+
function: {
1631+
name: 'clean_tool',
1632+
parameters: {
1633+
type: 'object',
1634+
properties: {
1635+
prop: { type: 'string', const: 'fixed' }
1636+
},
1637+
additionalProperties: false,
1638+
$schema: 'http://json-schema.org/draft-07/schema#'
1639+
}
1640+
}
1641+
}
1642+
]
1643+
};
1644+
1645+
const result = await transformRequestBody(body, codexInstructions);
1646+
const tool = (result.tools as any)[0];
1647+
const params = tool.function.parameters;
1648+
expect(params.additionalProperties).toBeUndefined();
1649+
expect(params.$schema).toBeUndefined();
1650+
expect(params.properties.prop.const).toBeUndefined();
1651+
});
15591652
});
15601653
});
15611654
});

0 commit comments

Comments
 (0)