Skip to content
74 changes: 74 additions & 0 deletions crates/apr-cli/src/code_output_format_help_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! #5211: `apr code --output-format json` help names the envelope that
//! `code_envelope::envelope` actually writes. It used to list only
//! `{type, subtype:"success", result, session_id, duration_ms}`, so a harness
//! reading `--help` could not learn that `backend` (the device that ran) or the
//! error object exist.

/// Every top-level key `envelope()` writes, the `backend` and `error` sub-keys,
/// and the two `subtype` values.
const ENVELOPE_FIELDS: [&str; 20] = [
"type",
"subtype",
"\"success\"",
"\"error\"",
"is_error",
"status",
"duration_ms",
"result",
"session_id",
"num_turns",
"tokens_in",
"tokens_out",
"total_cost_usd",
"backend",
"requested",
"ran",
"fell_back",
"kind",
"message",
"exit_code",
];

/// Clap's recursive build of the full `Commands` enum overflows the default
/// test-thread stack in debug builds, as in run_tests_top_k_default_3754.rs.
#[test]
fn code_output_format_help_names_every_envelope_field() {
std::thread::Builder::new()
.stack_size(16 * 1024 * 1024)
.spawn(code_output_format_help_body)
.expect("spawn")
.join()
.expect("--output-format help row panicked");
}

fn code_output_format_help_body() {
use clap::CommandFactory;
let cmd = crate::Cli::command();
let code = cmd.find_subcommand("code").expect("code subcommand");
let arg = code
.get_arguments()
.find(|a| a.get_id() == "output_format")
.expect("--output-format");
let arg_help = arg
.get_long_help()
.or_else(|| arg.get_help())
.expect("--output-format has help")
.to_string();
let json_value = arg
.get_possible_values()
.into_iter()
.find(|v| v.get_name() == "json")
.expect("json value");
let value_help = json_value
.get_help()
.expect("json value has help")
.to_string();
for (what, help) in [("--output-format", &arg_help), ("json value", &value_help)] {
for field in ENVELOPE_FIELDS {
assert!(
help.contains(field),
"{what} help omits envelope field {field}: {help}"
);
}
}
}
16 changes: 13 additions & 3 deletions crates/apr-cli/src/commands_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ pub enum CodeOutputFormat {
/// Plain assistant text on stdout (default; existing behavior).
#[default]
Text,
/// Structured JSON envelope: `{type:"result", subtype:"success", result, session_id, duration_ms}`.
/// Structured JSON envelope: `{type:"result", subtype:"success"|"error", is_error, status,
/// duration_ms, result, session_id, num_turns, tokens_in, tokens_out, total_cost_usd,
/// backend:{requested, ran, fell_back}|null}`, plus `error:{kind, message, exit_code}`
/// when `subtype` is `"error"`.
Json,
}

Expand Down Expand Up @@ -911,8 +914,11 @@ pub enum Commands {

/// Output format for non-interactive (`-p`) mode (PMAT-CODE-OUTPUT-FORMAT-001).
/// `text` (default): plain assistant text.
/// `json`: structured `{type:"result", subtype:"success", result, session_id, duration_ms}`
/// envelope matching Claude Code's `claude -p --output-format json` shape.
/// `json`: structured `{type:"result", subtype:"success"|"error", is_error, status,
/// duration_ms, result, session_id, num_turns, tokens_in, tokens_out, total_cost_usd,
/// backend:{requested, ran, fell_back}|null}` envelope, plus
/// `error:{kind, message, exit_code}` when `subtype` is `"error"`. The shape matches
/// Claude Code's `claude -p --output-format json`; `backend` names the device that ran.
#[arg(long, value_enum, default_value_t = CodeOutputFormat::Text)]
output_format: CodeOutputFormat,

Expand Down Expand Up @@ -1042,3 +1048,7 @@ pub enum DebugCommands {
force: bool,
},
}

#[cfg(all(test, feature = "training"))]
#[path = "code_output_format_help_tests.rs"]
mod code_output_format_help_tests;
14 changes: 14 additions & 0 deletions crates/aprender-serve/src/api/constrained_chat_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ fn constrained_turn(
}
}

/// 499, "client closed request": the status a turn cancelled by a disconnect is logged with.
fn client_closed_status() -> StatusCode {
StatusCode::from_u16(499).expect("499 is a valid status code")
}

/// The verdict on a finished constrained reply: `Ok` when it ships. A reply the budget cut is
/// `Truncated` (no partial document is returned); an empty one, or one the second reader
/// refuses, is `SchemaViolation`.
Expand Down Expand Up @@ -496,6 +501,15 @@ async fn constrained_chat(
))
},
};
if cancel.peek_cancelled() {
// The client went away mid-turn: no budget cut it, so it is not `Truncated`, and no
// one reads this reply
return Err(fail_response(
state,
client_closed_status(),
"the client closed the request before the constrained turn ended",
));
}
let reply = &turn.tokens[prompt_tokens..];
let text = mapped.model.decode(reply);
constrained_verdict(&constraint, stop, &text, reply.len())
Expand Down
64 changes: 64 additions & 0 deletions crates/aprender-serve/src/api/constrained_chat_backend_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,36 @@ async fn a_budget_too_small_for_the_document_is_422_with_no_partial_text() {
let (status, body) = post(state, constrained_body(schema, 1)).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(body["type"], "constraint_truncated");
assert!(
body["error"]
.as_str()
.is_some_and(|e| e.contains("budget of 1 tokens")),
"the refusal names the request's budget: {body}"
);
assert!(body.get("choices").is_none(), "{body}");
}

/// The disconnect path end to end: a turn whose token is already cancelled is answered 499,
/// never as the document or as a budget cut.
#[cfg(feature = "structured-output")]
#[tokio::test(flavor = "multi_thread")]
async fn a_turn_the_client_cancelled_is_answered_499_not_truncated() {
let (_f, state) = cpu_state(true);
let schema = serde_json::json!({"type": "boolean"});
let request: ChatCompletionRequest =
serde_json::from_value(constrained_body(schema, 8)).expect("a chat request");
let cancel = CancelToken::new();
cancel.cancel();
let response = try_constrained_chat(&state, &request, "id", None, Instant::now(), &cancel)
.await
.expect("a constrained request is answered here");
assert_eq!(response.status().as_u16(), 499);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("a body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("a JSON body");
assert!(body.get("choices").is_none(), "{body}");
assert_ne!(body["type"], "constraint_truncated", "{body}");
}

/// A server that retains no GGUF has no vocabulary to compile against: refused by name, never
Expand All @@ -525,3 +554,38 @@ async fn a_server_without_the_gguf_refuses_by_name() {
"{body}"
);
}

/// #3568: a `response_format.type` apr does not serve is a 422 that names the field and the
/// types it serves, not the sanitizer's "Invalid request body"; a known type still parses.
#[tokio::test(flavor = "multi_thread")]
async fn an_unknown_response_format_type_is_refused_naming_the_field() {
for (format, got) in [
(serde_json::json!({"type": "xml"}), "got xml"),
(serde_json::json!({"type": 12}), "got 12"),
(serde_json::json!({}), "got no type"),
] {
let (_f, state) = cpu_state(false);
let body = serde_json::json!({
"model": "default",
"messages": [{"role": "user", "content": "hi"}],
"response_format": format,
});
let (status, body) = post(state, body).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
let error = body["error"].as_str().expect("an error message");
assert!(
error.contains("response_format.type must be one of text, json_object, json_schema"),
"{error}"
);
assert!(error.ends_with(got), "{error}");
}
assert_eq!(
format(serde_json::json!({"type": "json_object"})),
ResponseFormat::JsonObject
);
// A known type goes through the request's own deserializer, the hook under test
let request = chat_request(serde_json::json!({"response_format": {"type": "json_object"}}));
assert_eq!(request.response_format, Some(ResponseFormat::JsonObject));
let request = chat_request(serde_json::json!({"response_format": null}));
assert_eq!(request.response_format, None);
}
46 changes: 44 additions & 2 deletions crates/aprender-serve/src/api/mod_create_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,13 @@ pub struct ChatCompletionRequest {
pub think: Option<bool>,
/// #3568 PR 4: OpenAI's structured output. `json_schema` and `json_object` run the turn
/// under a constraint and a second reader; `text` (or absent) is today's turn. A `type`
/// apr does not know is refused at deserialization, never dropped.
#[serde(default, skip_serializing_if = "Option::is_none")]
/// apr does not know is refused at deserialization, never dropped, with a message that
/// names the field and the types apr serves.
#[serde(
default,
deserialize_with = "response_format_of_known_type",
skip_serializing_if = "Option::is_none"
)]
pub response_format: Option<ResponseFormat>,
/// #4971: OpenAI's `logprobs`. When true the reply carries
/// `choices[0].logprobs`, one entry per generated token, or the backend
Expand Down Expand Up @@ -192,6 +197,43 @@ pub enum ResponseFormat {
},
}

/// The `type`s [`ResponseFormat`] serves, in the order a refusal lists them.
const RESPONSE_FORMAT_TYPES: [&str; 3] = ["text", "json_object", "json_schema"];

/// `deserialize_with` for `response_format` (#3568): a `type` apr does not serve is refused
/// with a client-visible reason naming the field, where serde's own "unknown variant" text is
/// hidden by the rejection sanitizer and the caller read only "Invalid request body".
fn response_format_of_known_type<'de, D>(
deserializer: D,
) -> Result<Option<ResponseFormat>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(None);
}
let kind = value.get("type").and_then(serde_json::Value::as_str);
if !kind.is_some_and(|k| RESPONSE_FORMAT_TYPES.contains(&k)) {
return Err(D::Error::custom(format!(
"{}response_format.type must be one of {}, got {}",
crate::api::CLIENT_VISIBLE_MARKER,
RESPONSE_FORMAT_TYPES.join(", "),
// Unquoted: the sanitizer trims a trailing `"` off a client-visible reason
match kind {
Some(k) => k.to_string(),
None => value
.get("type")
.map_or_else(|| "no type".to_string(), ToString::to_string),
}
)));
}
ResponseFormat::deserialize(value)
.map(Some)
.map_err(D::Error::custom)
}

/// The `json_schema` object of a `response_format` (#3568 PR 4).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonSchemaFormat {
Expand Down
Loading
Loading