Skip to content
Closed
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
2 changes: 1 addition & 1 deletion runtime/rust/prompty-openai/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ fn property_to_json_schema(prop: &Property, strict: bool) -> Result<Value, Schem
p.name.clone(),
property_to_json_schema_with_optional(p, !p.required.unwrap_or(false), strict)?,
);
if p.required.unwrap_or(false) {
if strict || p.required.unwrap_or(false) {
req.push(Value::String(p.name.clone()));
}
}
Expand Down
28 changes: 19 additions & 9 deletions runtime/rust/prompty-openai/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,11 +313,17 @@ async fn test_structured_output() {
"parser": { "kind": "prompty" },
},
"outputs": [
{ "name": "city", "kind": "string", "description": "The city name", "required": true },
{ "name": "country", "kind": "string", "description": "The country name", "required": true },
{ "name": "population", "kind": "integer", "description": "Approximate population", "required": true },
{
"name": "style",
"kind": "object",
"required": true,
"properties": [
{ "name": "color", "kind": "string", "required": true },
{ "name": "border", "kind": "string", "required": false }
]
},
],
"instructions": "system:\nYou are a geography expert. Return structured data.\nuser:\nTell me about Paris.",
"instructions": "system:\nReturn the requested visual style as structured data.\nuser:\nUse blue with no border.",
});
let agent = Prompty::load_from_value(&data, &LoadContext::default());

Expand All @@ -336,14 +342,18 @@ async fn test_structured_output() {
other => panic!("Expected object or JSON string, got: {other:?}"),
};

assert!(obj.contains_key("city"), "missing 'city' field: {obj:?}");
let style = obj
.get("style")
.and_then(Value::as_object)
.expect("missing object 'style' field");
assert!(style.get("color").is_some_and(Value::is_string));
assert!(
obj.contains_key("country"),
"missing 'country' field: {obj:?}"
style.contains_key("border"),
"missing 'border' field: {style:?}"
);
assert!(
obj.contains_key("population"),
"missing 'population' field: {obj:?}"
style["border"].is_null() || style["border"].is_string(),
"'border' must be nullable: {style:?}"
);
eprintln!("Structured output: {obj:?}");
}
Expand Down
5 changes: 3 additions & 2 deletions runtime/rust/prompty-openai/tests/wire_vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ wire_test!(image_wire);
wire_test!(kind_to_json_type_mapping);
wire_test!(chat_image_part);
wire_test!(structured_output);
wire_test!(structured_output_nested_optional);
wire_test!(options_max_completion_tokens);
wire_test!(options_stop_sequences);
wire_test!(options_additional_properties);
Expand Down Expand Up @@ -300,7 +301,7 @@ fn set_row_visual_schema_supports_nullable_unions_and_nested_optionality() {
assert_eq!(schema["properties"]["row"]["type"], "object");
assert_eq!(
schema["properties"]["row"]["required"],
json!(["color", "fill"])
json!(["color", "border", "fill"])
);
assert_eq!(
schema["properties"]["row"]["properties"]["color"]["type"],
Expand Down Expand Up @@ -328,7 +329,7 @@ fn set_row_visual_schema_supports_nullable_unions_and_nested_optionality() {
);
assert_eq!(
schema["properties"]["row"]["properties"]["fill"]["anyOf"][1]["required"],
json!(["theme"])
json!(["theme", "tint"])
);
assert_no_empty_type(&schema);
}
Expand Down
14 changes: 2 additions & 12 deletions runtime/typescript/packages/core/tests/spec-vectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,12 +751,7 @@ function buildAgentFromWireInput(input: any, name?: string): Prompty {
// Build tools
if (input.tools && input.tools.length > 0) {
agent.tools = input.tools.map((t: any) => {
const params = (t.parameters ?? []).map((p: any) => new Property({
name: p.name,
kind: p.kind,
required: p.required,
description: p.description,
}));
const params = (t.parameters ?? []).map((p: any) => Property.load(p));
const bindings = Object.entries(t.bindings ?? {}).map(([bname, bval]: [string, any]) =>
new Binding({ name: bname, input: typeof bval === "object" ? bval.input : String(bval) }),
);
Expand All @@ -773,12 +768,7 @@ function buildAgentFromWireInput(input: any, name?: string): Prompty {

// Build outputs
if (input.outputs && input.outputs.length > 0) {
agent.outputs = input.outputs.map((o: any) => new Property({
name: o.name,
kind: o.kind,
required: o.required,
description: o.description,
}));
agent.outputs = input.outputs.map((o: any) => Property.load(o));
}

return agent;
Expand Down
17 changes: 11 additions & 6 deletions spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1496,17 +1496,18 @@ A `list[Property]` (used for `inputs`, `outputs`, and `FunctionTool.parameters`)
MUST be converted to a JSON Schema object for wire transmission:

```
function schema_to_wire(properties: list[Property]) → dict:
function schema_to_wire(properties: list[Property], strict: bool = false) → dict:
schema = { type: "object", properties: {}, required: [] }

for prop in properties:
prop_schema = property_to_json_schema(prop)
optional = strict and not prop.required
prop_schema = property_to_json_schema(prop, strict, optional)
if prop.description:
prop_schema.description = prop.description
if prop.enumValues:
prop_schema.enum = prop.enumValues
schema.properties[prop.name] = prop_schema
if prop.required:
if strict or prop.required:
schema.required.append(prop.name)

if schema.required is empty:
Expand All @@ -1516,9 +1517,13 @@ function schema_to_wire(properties: list[Property]) → dict:
```

`property_to_json_schema` MUST recursively convert array items and object
properties. Object `required` arrays MUST contain only children whose
`Property.required` is true. For a concrete property with `nullable: true`,
the JSON Schema `type` MUST include both the concrete type and `"null"`.
properties. Outside provider strict mode, object `required` arrays MUST contain
only children whose `Property.required` is true. In OpenAI strict mode, this
rule MUST apply recursively: every property of every object MUST appear in that
object's `required` array, and a property whose `Property.required` is not true
MUST be represented as nullable. For a concrete property, nullability is
represented by a JSON Schema `type` containing both the concrete type and
`"null"`.

`kind: "union"` represents a portable union property. Exactly one of its
`oneOf` and `anyOf` arrays MUST be nonempty; implementations MUST reject
Expand Down
59 changes: 59 additions & 0 deletions spec/vectors/wire/wire_vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,65 @@
}
}
},
{
"name": "structured_output_nested_optional",
"description": "§7.1.4/§7.1.6 — OpenAI strict mode recursively requires every object property and represents optional fields as nullable.",
"input": {
"provider": "openai",
"apiType": "chat",
"model_id": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": [{ "kind": "text", "value": "Choose a visual style." }]
}
],
"tools": [],
"options": {},
"outputs": [
{
"name": "style",
"kind": "object",
"required": true,
"properties": [
{ "name": "color", "kind": "string", "required": true },
{ "name": "border", "kind": "string", "required": false }
]
}
]
},
"expected": {
"request_body": {
"model": "gpt-4o-mini",
"messages": [
{ "role": "user", "content": "Choose a visual style." }
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"strict": true,
"schema": {
"type": "object",
"properties": {
"style": {
"type": "object",
"properties": {
"color": { "type": "string" },
"border": { "type": ["string", "null"] }
},
"required": ["color", "border"],
"additionalProperties": false
}
},
"required": ["style"],
"additionalProperties": false
}
}
}
}
}
},
{
"name": "kind_to_json_type_mapping",
"description": "§7.1.4 — All Property kind values mapped to JSON Schema types: string→string, integer→integer, float→number, boolean→boolean, array→array, object→object.",
Expand Down
14 changes: 11 additions & 3 deletions web/src/content/docs/specification/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,18 @@ A `list[Property]` (used for `inputs`, `outputs`, and `FunctionTool.parameters`)
MUST be converted to a JSON Schema object for wire transmission:

```
function schema_to_wire(properties: list[Property]) → dict:
function schema_to_wire(properties: list[Property], strict: bool = false) → dict:
schema = { type: "object", properties: {}, required: [] }

for prop in properties:
prop_schema = { type: map_kind_to_json_type(prop.kind) }
optional = strict and not prop.required
prop_schema = property_to_json_schema(prop, strict, optional)
if prop.description:
prop_schema.description = prop.description
if prop.enumValues:
prop_schema.enum = prop.enumValues
schema.properties[prop.name] = prop_schema
if prop.required:
if strict or prop.required:
schema.required.append(prop.name)

if schema.required is empty:
Expand All @@ -198,6 +199,13 @@ function schema_to_wire(properties: list[Property]) → dict:
return schema
```

`property_to_json_schema` MUST recurse through array items and object
properties. Outside provider strict mode, each object lists only explicitly
required children in `required`. In OpenAI strict mode, every property of every
object MUST appear in that object's `required` array. A property that is
optional in the Prompty model MUST instead be represented as nullable in the
strict wire schema.

**Kind → JSON Schema type mapping.** Implementations MUST use this table:

| Property `kind` | JSON Schema `type` |
Expand Down
Loading