Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
744e3d0
fix(api): add openAiStrictToolSchemas setting for OpenAI-compatible g…
easonliang28 Sep 27, 2026
ae87b26
fix(i18n): complete openAiStrictToolSchemas strings for all locales
easonliang28 Sep 27, 2026
c3e5f00
test(visual): update OpenAI Compatible baselines for strict tool sche…
easonliang28 Sep 27, 2026
683fcae
Merge branch 'main' into feat/issue-63-openai-strict-tool-schemas
easonLiangWorldedtech Sep 28, 2026
d2a85b0
fix(api): clone tool schema properties before strict normalization
easonliang28 Sep 28, 2026
5bc91fc
Merge remote-tracking branch 'upstream/main' into feat/issue-63-opena…
Oct 2, 2026
66e9e9c
Merge commit 'refs/shapes/upstream-main' into feat/issue-63-openai-st…
Oct 2, 2026
27243f0
chore: trigger CodeRabbit re-review / label reconcile
Oct 2, 2026
4b338c1
Merge upstream main (3859e5dd8) into feat/issue-63-openai-strict-tool…
Oct 4, 2026
ddd7f66
Describe strict mode as optional for Chat Completions
Oct 4, 2026
38fd51f
Regenerate the OpenAI visual snapshots for the reworded description
Oct 4, 2026
c7c10c0
Fix grammatical agreement in the Russian description
Oct 4, 2026
6c71575
Fix Korean and Polish wording in the strict-schemas description
Oct 4, 2026
4fe577a
Re-run CI after a flaky visual snapshot failure
Oct 4, 2026
dca46c5
Cover the strict-schemas save round trip at the form layer
Oct 4, 2026
39434d6
test(openai): cover the strict flag on the O3-family streaming path
Oct 5, 2026
6a16638
Merge main into feat/issue-63-openai-strict-tool-schemas
Oct 5, 2026
8f9363c
test(webview-ui): assert a toggled-on value reaches the upsert payload
Oct 5, 2026
a58c286
test(webview-ui): type the round-trip helpers instead of any
Oct 5, 2026
849b67e
test(visual): regenerate the OpenAI Compatible baselines for the merg…
Oct 5, 2026
5f48a52
Merge branch 'main' into feat/issue-63-openai-strict-tool-schemas
easonLiangWorldedtech Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod"
import { providerDefinitionList, type ProviderDefinition } from "./provider-settings/index.js"
import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/common.js"
export {
DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS,
OPEN_AI_CODEX_SERVICE_TIER_KEY,
parseOpenAiExtraBody,
kimiCodeAuthMethodSchema,
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/provider-settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { basetenProviderDefinition } from "./baseten.js"
import type { ProviderDefinition } from "./common.js"

export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js"
export { parseOpenAiExtraBody } from "./openai.js"
export { parseOpenAiExtraBody, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS } from "./openai.js"
export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js"
export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js"
export {
Expand Down
9 changes: 9 additions & 0 deletions packages/types/src/provider-settings/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

export const OPEN_AI_MODEL_ID_FIELD = "openAiModelId"

/**
* Default for the OpenAI `openAiStrictToolSchemas` setting.
* When true (default), non-MCP tools are sent with strict: true and their
* schemas are normalized for OpenAI strict mode. Some OpenAI-compatible
* proxies reject strict: true; set this to false for those endpoints.
*/
export const DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS = true

Check warning on line 15 in packages/types/src/provider-settings/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/provider-settings/openai.ts:15: Survived BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.

const OPENAI_EXTRA_BODY_RESERVED_KEYS = [
// Prototype-pollution defenses; remaining keys are request-owned, including tool-call protocol controls.
"__proto__",
Expand Down Expand Up @@ -83,6 +91,7 @@
openAiBaseUrl: z.string().optional(),
openAiApiKey: z.string().optional(),
openAiR1FormatEnabled: z.boolean().optional(),
openAiStrictToolSchemas: z.boolean().optional(),
[OPEN_AI_MODEL_ID_FIELD]: z.string().optional(),
openAiCustomModelInfo: modelInfoSchema.nullish(),
openAiUseAzure: z.boolean().optional(),
Expand Down
172 changes: 170 additions & 2 deletions src/api/providers/__tests__/base-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class TestProvider extends BaseProvider {
}

// Expose protected method for testing
public testConvertToolsForOpenAI(tools: any[] | undefined): any[] | undefined {
return this.convertToolsForOpenAI(tools)
public testConvertToolsForOpenAI(tools: any[] | undefined, strict?: boolean): any[] | undefined {
return this.convertToolsForOpenAI(tools, strict)
}
}

Expand Down Expand Up @@ -179,6 +179,96 @@ describe("BaseProvider", () => {
})
})

it("leaves property entries that are not objects unchanged", () => {
const schema = {
type: "object",
properties: {
broken: null,
list: ["string", "null"],
note: "plain",
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

// The clone guard only normalises object entries, so a null entry, an
// array-valued entry and a plain string entry are passed through as declared.
expect(result.properties.broken).toBe(null)
expect(result.properties.list).toEqual(["string", "null"])
expect(result.properties.note).toBe("plain")
})

it("keeps a nullable type with more than one non-null type as an array", () => {
const schema = {
type: "object",
properties: {
value: { type: ["string", "null", "number"] },
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

expect(result.properties.value.type).toEqual(["string", "number"])
})

it("does not treat a plain property as an array of objects", () => {
const schema = {
type: "object",
properties: {
path: { type: "string" },
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

// Only array properties get an items conversion, so a plain property
// keeps exactly the keys it declared.
expect(Object.keys(result.properties.path)).toEqual(["type"])
})

it("does not convert items on a non-array property that carries object items", () => {
const schema = {
type: "object",
properties: {
weird: {
type: "string",
items: { type: "object", properties: { a: { type: "string" } } },
},
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

expect(result.properties.weird.items.additionalProperties).toBeUndefined()
})

it("leaves an array property without items unchanged", () => {
const schema = {
type: "object",
properties: {
tags: { type: "array" },
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

expect(Object.keys(result.properties.tags)).toEqual(["type"])
})

it("leaves a property whose type is only null unchanged", () => {
const schema = {
type: "object",
properties: {
only: { type: "null" },
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)

// The nullable branch only handles array types; a bare null type is not a
// list to filter.
expect(result.properties.only.type).toBe("null")
})
describe("convertToolsForOpenAI", () => {
it("should return undefined for undefined input", () => {
const result = provider.testConvertToolsForOpenAI(undefined)
Expand Down Expand Up @@ -346,6 +436,84 @@ describe("BaseProvider", () => {
expect(result?.[0].function.name).toBe("read\uFFFDfile")
})

it("should set strict: false and preserve declared schema when strict is disabled", () => {
const tools = [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: {
path: { type: "string" },
offset: { type: "integer" },
},
required: ["path"],
},
},
},
]

const result = provider.testConvertToolsForOpenAI(tools, false)

expect(result?.[0].function.strict).toBe(false)
// Declared schema preserved: original required array, no additionalProperties coercion
expect(result?.[0].function.parameters).toEqual(tools[0].function.parameters)
})

it("should not mutate caller-owned schemas during strict normalization", () => {
const tools = [
{
type: "function",
function: {
name: "nullable_tool",
description: "Tool with a nullable property",
parameters: {
type: "object",
properties: {
path: { type: ["string", "null"] },
},
required: ["path"],
},
},
},
]

const strictResult = provider.testConvertToolsForOpenAI(tools, true)
expect(strictResult?.[0].function.parameters.properties.path.type).toBe("string")

// Caller-owned schema is untouched, so a later non-strict request
// can still send the original nullable type.
expect(tools[0].function.parameters.properties.path.type).toEqual(["string", "null"])

const nonStrictResult = provider.testConvertToolsForOpenAI(tools, false)
expect(nonStrictResult?.[0].function.parameters).toEqual(tools[0].function.parameters)
})

it("should still set strict: false for MCP tools when strict is disabled", () => {
const tools = [
{
type: "function",
function: {
name: "mcp--github--get_me",
description: "Get current user",
parameters: {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
},
},
},
]

const result = provider.testConvertToolsForOpenAI(tools, false)

expect(result?.[0].function.strict).toBe(false)
expect(result?.[0].function.parameters).toEqual(tools[0].function.parameters)
})
it("should preserve non-function tools unchanged", () => {
const tools = [
{
Expand Down
Loading
Loading