Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 16 additions & 7 deletions internal/translate/strictify_openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ func strictifyNode(node map[string]any, depth int, propCount *int) (out map[stri
return nil, false
}
if _, req := originallyRequired[name]; !req {
// An optional property with no strict-expressible type (a bare {}
// "any JSON value", e.g. Workflow.args) cannot be made nullable
// without either admitting "object" — which strict mode then
// requires to carry additionalProperties:false, forbidding the
// arbitrary keys the schema exists to accept — or narrowing the
// value space and silently dropping valid object/array args.
// There is no strict representation of an open value, so bail to
// non-strict emission per this file's fail-open contract.
Comment on lines +171 to +178

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// An optional property with no strict-expressible type (a bare {}
// "any JSON value", e.g. Workflow.args) cannot be made nullable
// without either admitting "object" — which strict mode then
// requires to carry additionalProperties:false, forbidding the
// arbitrary keys the schema exists to accept — or narrowing the
// value space and silently dropping valid object/array args.
// There is no strict representation of an open value, so bail to
// non-strict emission per this file's fail-open contract.
// A bare {} optional (open value) has no valid strict form: admitting
// "object" requires additionalProperties:false (forbidding arbitrary keys),
// and narrowing to non-object types drops valid structured args. Bail.

8 lines explaining a 2-step constraint; the WHY fits in 3.

if !schemaHasStrictType(sp) {
return nil, false
}
sp = makeNullable(sp)
}
outProps[name] = sp
Expand Down Expand Up @@ -206,13 +217,11 @@ func makeNullable(node map[string]any) map[string]any {
node["anyOf"] = append(branches, map[string]any{"type": "null"})
return node
}
// No type and no anyOf: give the synthetic branch an explicit value-type union
// so OpenAI strict mode accepts it; preserve any inline constraints on the original node.
branch := map[string]any{"type": []any{"string", "number", "boolean", "object", "array", "null"}}
for k, v := range node {
branch[k] = v
}
return map[string]any{"anyOf": []any{branch, map[string]any{"type": "null"}}}
// No type and no anyOf (e.g. bare enum): wrap the node itself. Callers must
// not pass a fully typeless node here — strictifyNode bails on typeless
// optionals before reaching this point, since an open value has no strict
// representation (see the schemaHasStrictType guard in strictifyNode).
Comment on lines +220 to +223

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// No type and no anyOf (e.g. bare enum): wrap the node itself. Callers must
// not pass a fully typeless node here — strictifyNode bails on typeless
// optionals before reaching this point, since an open value has no strict
// representation (see the schemaHasStrictType guard in strictifyNode).
// No type and no anyOf (e.g. bare enum): wrap node. Typeless optionals
// are rejected by strictifyNode before reaching here.

4 lines; the caller-contract note and cross-reference both fit in 2.

return map[string]any{"anyOf": []any{node, map[string]any{"type": "null"}}}
}

// schemaHasStrictType reports whether node carries a type OpenAI strict mode
Expand Down
21 changes: 8 additions & 13 deletions internal/translate/strictify_openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,21 +236,16 @@ func TestStrictify_UnevaluatedPropertiesBails(t *testing.T) {
require.False(t, ok, "unevaluatedProperties is not expressible in strict mode; must bail")
}

// Workflow.args is bare {} — makeNullable's synthetic anyOf branch had no 'type' key
// and OpenAI strict mode 400'd; verify the fix adds an explicit value-type union.
func TestStrictify_TypelessOptionalGetsExplicitValueType(t *testing.T) {
out, ok := strictifyFromJSON(t, `{
// Workflow.args is a bare {} "any JSON value" optional. #829 kept it strict by
// synthesizing a 6-type union branch, but that branch admits "object" without
// additionalProperties:false, so OpenAI strict mode still 400'd
// (context=('properties','args','anyOf','0','type','3')). An open value has no
// strict representation, so the whole tool must bail to non-strict emission.
Comment on lines +239 to +243

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Workflow.args is a bare {} "any JSON value" optional. #829 kept it strict by
// synthesizing a 6-type union branch, but that branch admits "object" without
// additionalProperties:false, so OpenAI strict mode still 400'd
// (context=('properties','args','anyOf','0','type','3')). An open value has no
// strict representation, so the whole tool must bail to non-strict emission.
// Workflow.args is a bare {} optional; an open value has no strict form
// (#829's 6-type union still 400'd because "object" needs additionalProperties:false).

5 lines; the key WHY fits in 2.

func TestStrictify_TypelessOptionalBails(t *testing.T) {
_, ok := strictifyFromJSON(t, `{
"type":"object",
"properties":{"args":{}},
"required":[]
}`)
require.True(t, ok, "a typeless optional property must survive strictification, not bail")

args := out["properties"].(map[string]any)["args"].(map[string]any)
branches, has := args["anyOf"].([]any)
require.True(t, has, "a typeless optional is made nullable via anyOf")
require.Len(t, branches, 2)
assert.Equal(t, []any{"string", "number", "boolean", "object", "array", "null"}, branches[0].(map[string]any)["type"],
"a typeless branch must carry an explicit value-type union so OpenAI strict mode doesn't 400")
assert.Equal(t, map[string]any{"type": "null"}, branches[1])
require.False(t, ok, "a bare {} optional (any JSON value) has no strict form; must fall back to non-strict")
}
Loading