Skip to content

Commit 713cd65

Browse files
committed
fix(llm): stabilize JSON-object generation
Users reported unstable JSON-object generation. Two root causes: 1. Structured output never used provider-native guarantees: the synthetic `emit_<schema>` tool was only *offered* (no `tool_choice`), and Strict/Json modes collapsed to Tool universally — so the model could emit prose or malformed args ("parse-and-pray"). 2. The planner / pre-analysis JSON path used a naive first-`{`/last-`}` slice with no fence handling and no repair, hard-erroring on fenced/prosey output. Fix (Tier 1 + Tier 2): - LlmClient: additive `native_structured_support()`, `complete_structured()`, `complete_streaming_structured()` with default impls (non-breaking — existing clients/mocks keep working). - structured.rs: capability-aware `resolve_mode` + `StructuredDirective`. Force `tool_choice` (Tool/Auto), and request native `response_format` (Strict→json_schema+strict, Json→json_object) on capable providers, falling back to forced Tool mode otherwise. - anthropic/openai/zhipu: honor the directive (Anthropic forced tool_choice; OpenAI tool_choice + response_format; Zhipu delegates to its inner client). - llm_planner: reuse the robust shared extractor + add one repair retry in `pre_analyze`. - generate_object: stop pre-collapsing Strict/Json to Tool (engine resolves it). Tests: - Deep adversarial unit tests: capability/directive routing, provider wire-format (tool_choice/response_format), adversarial JSON extraction, planner fence/prose/brace-in-string + repair-retry. 1811 lib tests green, fmt + clippy clean. - New `#[ignore]` real-LLM integration test (tests/test_structured_json_real_llm.rs). Validated end-to-end against gpt-4o via the real gateway in .a3s/config.acl: forced tool_choice 5/5 stable (0 repairs), json_object ok, pre_analyze ok. Notes: - Strict json_schema is opt-in only; Auto/Tool never send response_format. - Follow-up: override complete_streaming_structured on providers so the streaming structured path also forces the directive (today it uses the non-forcing default).
1 parent 63d2647 commit 713cd65

9 files changed

Lines changed: 1083 additions & 93 deletions

File tree

core/src/llm/anthropic.rs

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Anthropic Claude LLM client
22
33
use super::http::{default_http_client, normalize_base_url, HttpClient};
4+
use super::structured;
45
use super::types::*;
56
use super::LlmClient;
67
use crate::retry::{AttemptOutcome, RetryConfig};
@@ -152,17 +153,24 @@ impl AnthropicClient {
152153
}
153154
}
154155

155-
#[async_trait]
156-
impl LlmClient for AnthropicClient {
157-
async fn complete(
158-
&self,
159-
messages: &[Message],
160-
system: Option<&str>,
161-
tools: &[ToolDefinition],
162-
) -> Result<LlmResponse> {
156+
impl AnthropicClient {
157+
/// Apply a structured-output directive to an Anthropic request.
158+
///
159+
/// Anthropic supports forced tool choice (`tool_choice`) but has no
160+
/// `response_format`, so only `force_tool` is honored.
161+
fn apply_directive(
162+
request: &mut serde_json::Value,
163+
directive: &structured::StructuredDirective,
164+
) {
165+
if let Some(tool) = &directive.force_tool {
166+
request["tool_choice"] = serde_json::json!({ "type": "tool", "name": tool });
167+
}
168+
}
169+
170+
/// Execute a fully-built (non-streaming) request body.
171+
async fn send_request(&self, request_body: serde_json::Value) -> Result<LlmResponse> {
163172
{
164173
let request_started_at = Instant::now();
165-
let request_body = self.build_request(messages, system, tools);
166174
let url = format!("{}/v1/messages", self.base_url);
167175

168176
let headers = vec![
@@ -259,6 +267,35 @@ impl LlmClient for AnthropicClient {
259267
Ok(llm_response)
260268
}
261269
}
270+
}
271+
272+
#[async_trait]
273+
impl LlmClient for AnthropicClient {
274+
async fn complete(
275+
&self,
276+
messages: &[Message],
277+
system: Option<&str>,
278+
tools: &[ToolDefinition],
279+
) -> Result<LlmResponse> {
280+
self.send_request(self.build_request(messages, system, tools))
281+
.await
282+
}
283+
284+
async fn complete_structured(
285+
&self,
286+
messages: &[Message],
287+
system: Option<&str>,
288+
tools: &[ToolDefinition],
289+
directive: &structured::StructuredDirective,
290+
) -> Result<LlmResponse> {
291+
let mut request_body = self.build_request(messages, system, tools);
292+
Self::apply_directive(&mut request_body, directive);
293+
self.send_request(request_body).await
294+
}
295+
296+
fn native_structured_support(&self) -> structured::NativeStructuredSupport {
297+
structured::NativeStructuredSupport::ForcedTool
298+
}
262299

263300
async fn complete_streaming(
264301
&self,
@@ -739,4 +776,40 @@ mod tests {
739776
assert_eq!(req["max_tokens"], 16_000);
740777
assert_eq!(req["thinking"]["budget_tokens"], 8_000);
741778
}
779+
780+
#[test]
781+
fn test_apply_directive_forces_tool_choice() {
782+
let mut req = serde_json::json!({ "model": "m", "messages": [] });
783+
let directive = structured::StructuredDirective {
784+
force_tool: Some("emit_person".to_string()),
785+
response_format: None,
786+
};
787+
AnthropicClient::apply_directive(&mut req, &directive);
788+
assert_eq!(req["tool_choice"]["type"], "tool");
789+
assert_eq!(req["tool_choice"]["name"], "emit_person");
790+
}
791+
792+
#[test]
793+
fn test_apply_directive_ignores_response_format() {
794+
// Anthropic has no response_format; both a response_format-only and an
795+
// empty directive must be no-ops.
796+
let mut req = serde_json::json!({ "model": "m" });
797+
AnthropicClient::apply_directive(
798+
&mut req,
799+
&structured::StructuredDirective {
800+
force_tool: None,
801+
response_format: Some(structured::ResponseFormat::JsonObject),
802+
},
803+
);
804+
assert!(req.get("response_format").is_none());
805+
assert!(req.get("tool_choice").is_none());
806+
}
807+
808+
#[test]
809+
fn test_native_structured_support_is_forced_tool() {
810+
assert_eq!(
811+
make_client().native_structured_support(),
812+
structured::NativeStructuredSupport::ForcedTool
813+
);
814+
}
742815
}

core/src/llm/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ pub trait LlmClient: Send + Sync {
4848
tools: &[ToolDefinition],
4949
cancel_token: CancellationToken,
5050
) -> Result<mpsc::Receiver<StreamEvent>>;
51+
52+
/// Report the strongest provider-native structured-output enforcement this
53+
/// client supports. Used by [`structured`] to decide whether to force a
54+
/// tool call, request a native `response_format`, or fall back to
55+
/// prompt-and-parse. Defaults to no native support.
56+
fn native_structured_support(&self) -> structured::NativeStructuredSupport {
57+
structured::NativeStructuredSupport::None
58+
}
59+
60+
/// Complete a conversation while honoring a structured-output directive
61+
/// (forced `tool_choice` and/or native `response_format`).
62+
///
63+
/// The default implementation ignores the directive and behaves exactly
64+
/// like [`LlmClient::complete`], so existing clients keep working unchanged;
65+
/// providers that support native structured output override this.
66+
async fn complete_structured(
67+
&self,
68+
messages: &[Message],
69+
system: Option<&str>,
70+
tools: &[ToolDefinition],
71+
_directive: &structured::StructuredDirective,
72+
) -> Result<LlmResponse> {
73+
self.complete(messages, system, tools).await
74+
}
75+
76+
/// Streaming counterpart of [`LlmClient::complete_structured`]. Defaults to
77+
/// [`LlmClient::complete_streaming`], ignoring the directive.
78+
async fn complete_streaming_structured(
79+
&self,
80+
messages: &[Message],
81+
system: Option<&str>,
82+
tools: &[ToolDefinition],
83+
_directive: &structured::StructuredDirective,
84+
cancel_token: CancellationToken,
85+
) -> Result<mpsc::Receiver<StreamEvent>> {
86+
self.complete_streaming(messages, system, tools, cancel_token)
87+
.await
88+
}
5189
}
5290

5391
// Include test modules — these reference internal types via crate paths

0 commit comments

Comments
 (0)