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
5 changes: 5 additions & 0 deletions crates/jp_cli/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,11 @@ impl From<jp_llm::Error> for Error {
("tier", tier.to_string()),
]
.into(),
error @ UnsupportedOutputSchema { .. } => [
("message", "Unsupported structured output schema".into()),
("error", error.to_string()),
]
.into(),
Stream(stream_error) => [
("message", "Stream error".into()),
("error", stream_error.to_string()),
Expand Down
11 changes: 11 additions & 0 deletions crates/jp_llm/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,17 @@ pub enum Error {
tier: ServiceTier,
},

/// The provider always constrains structured output to closed objects, and
/// the schema has an object that closing would change.
///
/// Refused rather than closed: a free-form object would only ever come back
/// as `{}`, and a map type would lose its extra keys.
#[error(
"The `{provider}` provider cannot constrain structured output to this schema: it contains \
an object with no declared properties, or one that allows additional properties"
)]
UnsupportedOutputSchema { provider: ProviderId },

#[error("Invalid JSON: {0}")]
Json(#[from] serde_json::Error),

Expand Down
11 changes: 11 additions & 0 deletions crates/jp_llm/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use jp_config::{
model::id::{Name, ProviderId},
providers::llm::LlmProviderConfig,
};
use jp_tool::schema::{Node, has_unconstrained_node};
use llamacpp::Llamacpp;
use ollama::Ollama;
use openai::Openai;
Expand Down Expand Up @@ -171,6 +172,16 @@ pub(crate) fn build_request_value(
}
}

/// Whether a structured-output schema has an object a strict,
/// grammar-constrained mode cannot express: one with no declared properties, or
/// one that allows additional properties.
///
/// The root counts too, since for structured output it is the answer itself.
pub(crate) fn output_schema_is_open(schema: &Map<String, Value>) -> bool {
let schema = Value::Object(schema.clone());
Node::root(&schema).is_open_object() || has_unconstrained_node(&schema)
}

/// Serialize a value to a temporary JSON file and return its path as a string.
///
/// Used by `trace!` fields to avoid dumping massive request payloads into the
Expand Down
26 changes: 21 additions & 5 deletions crates/jp_llm/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ use jp_conversation::{
event::{ChatResponse, ConversationEvent, EventKind},
};
use jp_credentials::CredentialStore;
use jp_tool::ToolDefinition;
use jp_tool::{ToolDefinition, schema::has_unconstrained_node};
use serde_json::{Map, Value, json};
use tracing::{debug, info, trace, warn};

use self::resolve::Route;
use super::{Provider, trace_to_tmpfile};
use super::{Provider, output_schema_is_open, trace_to_tmpfile};
use crate::{
credential::Credential,
error::{
Expand Down Expand Up @@ -1761,12 +1761,23 @@ fn create_request(
builder.stream(stream);

// Extract schema and config before into_parts() consumes the thread.
//
// Anthropic has no unconstrained structured output mode, and its grammar
// requires every object closed, so an open object cannot be sent as
// declared.
let format = thread
.events
.schema()
.map(|schema| JsonOutputFormat::JsonSchema {
schema: transform_schema(schema),
});
.map(|schema| {
if output_schema_is_open(&schema) {
return Err(Error::UnsupportedOutputSchema { provider: PROVIDER });
}

Ok(JsonOutputFormat::JsonSchema {
schema: transform_schema(schema),
})
})
.transpose()?;

let config = thread.events.config()?;
let cache = resolve_cache_control(config.assistant.request.cache);
Expand Down Expand Up @@ -2714,6 +2725,11 @@ fn convert_tools(
let mut tools: Vec<_> = tools
.into_iter()
.map(|tool| {
// Strict mode closes every object, which would leave the model
// only `{}` for an open one or drop a map type's extra keys, so a
// tool with an open object is sent unstrict.
let strict = strict && !has_unconstrained_node(&tool.parameters);

types::Tool::Custom(types::CustomTool {
name: tool.name,
description: tool.docs.schema_description().map(str::to_owned),
Expand Down
143 changes: 142 additions & 1 deletion crates/jp_llm/src/provider/anthropic_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1776,7 +1776,12 @@ fn test_adaptive_thinking_with_structured_output() {
features: vec!["adaptive-thinking"],
};

let schema = Map::from_iter([("type".into(), json!("object"))]);
// A fixed shape: a bare `{"type": "object"}` is free-form, which
// Anthropic cannot constrain.
let schema = Map::from_iter([
("type".into(), json!("object")),
("properties".into(), json!({"name": {"type": "string"}})),
]);

let events = ConversationStream::new_test().with_turn(ChatRequest {
content: "Extract data".into(),
Expand Down Expand Up @@ -1812,6 +1817,7 @@ fn test_adaptive_thinking_with_structured_output() {
assert_eq!(output_config.effort, Some(Effort::High));
let expected_schema = Map::from_iter([
("type".into(), json!("object")),
("properties".into(), json!({"name": {"type": "string"}})),
("additionalProperties".into(), json!(false)),
]);
assert_eq!(
Expand Down Expand Up @@ -2787,6 +2793,141 @@ fn test_create_request_drops_trailing_redacted_thinking() {
));
}

mod strict_tools {
use jp_tool::{ToolDefinition, ToolDocs};
use serde_json::{Value, json};

use super::convert_tools;

/// One tool converted with strict tool use available, as it goes on the
/// wire.
fn converted(parameters: Value) -> Value {
let tools = convert_tools(
vec![ToolDefinition {
name: "post".to_owned(),
docs: ToolDocs::default(),
parameters,
}],
true,
None,
&mut 0,
);

serde_json::to_value(tools.first().expect("one tool")).expect("serializable tool")
}

/// Strict tool use requires `additionalProperties: false` on every object,
/// which would leave the model only `{}` for `body`.
#[test]
fn a_free_form_object_parameter_is_sent_unstrict() {
let parameters = json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"body": { "type": "object" }
},
"required": ["url", "body"]
});

let tool = converted(parameters.clone());

assert_eq!(tool.get("strict"), None);
assert_eq!(tool["input_schema"], parameters);
}

/// Strict mode would overwrite `additionalProperties` and drop the extra
/// keys a map type admits.
#[test]
fn a_map_type_parameter_is_sent_unstrict() {
let parameters = json!({
"type": "object",
"properties": {
"headers": {
"type": "object",
"properties": { "host": { "type": "string" } },
"additionalProperties": { "type": "string" }
}
}
});

let tool = converted(parameters.clone());

assert_eq!(tool.get("strict"), None);
assert_eq!(tool["input_schema"], parameters);
}

#[test]
fn a_fixed_shape_stays_strict() {
let tool = converted(json!({
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
}));

assert_eq!(tool["strict"], json!(true));
assert_eq!(
tool["input_schema"],
json!({
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"],
"additionalProperties": false
})
);
}
}

/// Anthropic always constrains structured output, and every object in the
/// grammar must be closed.
/// A free-form object could only ever come back as `{}`, so the request is
/// refused rather than answered with that.
#[test]
fn structured_output_with_a_free_form_object_is_refused() {
let model = ModelDetails {
id: (PROVIDER, "claude-sonnet-4-5").try_into().unwrap(),
display_name: None,
context_window: Some(200_000),
max_output_tokens: Some(64_000),
reasoning: None,
knowledge_cutoff: None,
deprecated: None,
structured_output: Some(true),
prefill: None,
subscription: None,
features: vec![],
};

let schema = Map::from_iter([
("type".into(), json!("object")),
("properties".into(), json!({"meta": {"type": "object"}})),
]);
let events = ConversationStream::new_test().with_turn(ChatRequest {
content: "Extract".into(),
schema: Some(schema),
author: None,
});
let query = ChatQuery {
thread: Thread {
system_prompt: None,
sections: vec![],
attachments: vec![],
events,
},
tools: vec![],
tool_choice: ToolChoice::Auto,
truncation: Truncation::default(),
};

let error = create_request(&model, query, true, &BetaFeatures(vec![]), false)
.expect_err("an open object cannot be constrained");

assert_eq!(
error.to_string(),
"The `anthropic` provider cannot constrain structured output to this schema: it contains \
an object with no declared properties, or one that allows additional properties"
);
}

mod transform_schema {
use serde_json::{Map, Value, json};

Expand Down
16 changes: 13 additions & 3 deletions crates/jp_llm/src/provider/cerebras.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::{
error::{Error, Result, StreamError, StreamErrorKind},
event::{Event, FinishReason},
model::{ModelDeprecation, ReasoningDetails},
provider::trace_to_tmpfile,
provider::{output_schema_is_open, trace_to_tmpfile},
query::ChatQuery,
stream::with_tool_call_keepalive,
};
Expand Down Expand Up @@ -568,12 +568,22 @@ fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool
}

if let Some(schema) = structured_schema {
// Strict mode closes every object, which would leave the model only
// `{}` for an open one. Unstrict, the schema is a hint and goes as
// declared, since the strict-subset rules no longer apply.
let strict = !output_schema_is_open(&schema);
let schema = if strict {
transform_schema(schema)
} else {
Value::Object(schema)
};

body["response_format"] = json!({
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": transform_schema(schema),
"strict": true,
"schema": schema,
"strict": strict,
},
});
}
Expand Down
78 changes: 77 additions & 1 deletion crates/jp_llm/src/provider/cerebras_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use eventsource_stream::Event as MessageEvent;
use futures::StreamExt as _;
use jp_config::assistant::sections::SectionConfig;
use jp_conversation::{ConversationEvent, event::ToolCallRequest};
use jp_conversation::{
ConversationEvent,
event::{ChatRequest, ToolCallRequest},
};
use reqwest_eventsource::Error as SseError;

use super::*;
Expand Down Expand Up @@ -715,6 +718,79 @@ fn transform_schema_forces_strict_objects() {
assert!(nested_req.contains(&json!("value")));
}

/// The `response_format` a request for `schema` sends.
fn response_format_for(schema: Value) -> Value {
let model = ModelDetails::empty((PROVIDER, "future-model-99").try_into().unwrap());
let events = jp_conversation::ConversationStream::new_test().with_turn(ChatRequest {
content: "Extract".into(),
schema: Some(serde_json::from_value(schema).unwrap()),
author: None,
});
let query = ChatQuery {
thread: jp_conversation::thread::Thread {
system_prompt: None,
sections: vec![],
attachments: vec![],
events,
},
tools: vec![],
tool_choice: ToolChoice::Auto,
truncation: Truncation::default(),
};

let (body, _) = create_request(&model, query).unwrap();
body["response_format"].clone()
}

/// Strict mode would close `meta` and leave the model only `{}` for it.
/// With `strict: false` the schema is a hint, so it goes as declared.
#[test]
fn structured_output_with_a_free_form_object_is_unstrict() {
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string", "pattern": "^[a-z]+$" },
"meta": { "type": "object" }
},
"required": ["name"]
});

assert_eq!(
response_format_for(schema.clone()),
json!({
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": schema,
"strict": false
}
})
);
}

#[test]
fn structured_output_with_a_fixed_shape_is_strict() {
let format = response_format_for(json!({
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}));

assert_eq!(
format["json_schema"],
json!({
"name": "structured_output",
"schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"],
"additionalProperties": false
},
"strict": true
})
);
}

#[test]
fn transform_schema_preserves_number_constraints() {
let schema: serde_json::Map<String, Value> = serde_json::from_value(json!({
Expand Down
Loading
Loading