Skip to content

Commit dfb7acc

Browse files
committed
fix(llm): force structured directive on the streaming path too
Follow-up to the blocking-path fix: providers now override `complete_streaming_structured` so streaming structured generation also forces `tool_choice` / sets native `response_format`, instead of falling back to the non-forcing default. - anthropic/openai: extract `send_streaming` from `complete_streaming`; the trait methods become thin wrappers, and `complete_streaming_structured` applies the directive before executing. The large streaming parsers are unchanged. - zhipu: already delegates to its inner client (no change). - tests: RecordingClient records the streaming directive; new unit test asserts `generate_streaming` forces the tool; new `#[ignore]` real-LLM streaming case. Validated against gpt-4o: streaming forced tool_choice -> 8 partials, valid object (5/5 integration cases pass).
1 parent 713cd65 commit dfb7acc

4 files changed

Lines changed: 172 additions & 31 deletions

File tree

core/src/llm/anthropic.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,34 @@ impl LlmClient for AnthropicClient {
303303
system: Option<&str>,
304304
tools: &[ToolDefinition],
305305
cancel_token: CancellationToken,
306+
) -> Result<mpsc::Receiver<StreamEvent>> {
307+
self.send_streaming(self.build_request(messages, system, tools), cancel_token)
308+
.await
309+
}
310+
311+
async fn complete_streaming_structured(
312+
&self,
313+
messages: &[Message],
314+
system: Option<&str>,
315+
tools: &[ToolDefinition],
316+
directive: &structured::StructuredDirective,
317+
cancel_token: CancellationToken,
318+
) -> Result<mpsc::Receiver<StreamEvent>> {
319+
let mut request_body = self.build_request(messages, system, tools);
320+
Self::apply_directive(&mut request_body, directive);
321+
self.send_streaming(request_body, cancel_token).await
322+
}
323+
}
324+
325+
impl AnthropicClient {
326+
/// Execute a fully-built streaming request body (sets `stream: true`).
327+
async fn send_streaming(
328+
&self,
329+
mut request_body: serde_json::Value,
330+
cancel_token: CancellationToken,
306331
) -> Result<mpsc::Receiver<StreamEvent>> {
307332
{
308333
let request_started_at = Instant::now();
309-
let mut request_body = self.build_request(messages, system, tools);
310334
request_body["stream"] = serde_json::json!(true);
311335

312336
let url = format!("{}/v1/messages", self.base_url);

core/src/llm/openai.rs

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -516,37 +516,40 @@ impl LlmClient for OpenAiClient {
516516
tools: &[ToolDefinition],
517517
cancel_token: tokio_util::sync::CancellationToken,
518518
) -> Result<mpsc::Receiver<StreamEvent>> {
519-
{
520-
let request_started_at = Instant::now();
521-
let mut openai_messages = Vec::new();
522-
523-
if let Some(sys) = system {
524-
openai_messages.push(serde_json::json!({
525-
"role": "system",
526-
"content": sys,
527-
}));
528-
}
529-
530-
openai_messages.extend(self.convert_messages(messages));
531-
532-
let mut request = serde_json::json!({
533-
"model": self.model,
534-
"messages": openai_messages,
535-
"stream": true,
536-
"stream_options": { "include_usage": true },
537-
});
538-
539-
if let Some(temp) = self.temperature {
540-
request["temperature"] = serde_json::json!(temp);
541-
}
542-
if let Some(max) = self.max_tokens {
543-
request["max_tokens"] = serde_json::json!(max);
544-
}
519+
self.send_streaming(
520+
self.build_chat_request(messages, system, tools, None),
521+
cancel_token,
522+
)
523+
.await
524+
}
545525

546-
if !tools.is_empty() {
547-
request["tools"] = serde_json::json!(self.convert_tools(tools));
548-
}
526+
async fn complete_streaming_structured(
527+
&self,
528+
messages: &[Message],
529+
system: Option<&str>,
530+
tools: &[ToolDefinition],
531+
directive: &structured::StructuredDirective,
532+
cancel_token: tokio_util::sync::CancellationToken,
533+
) -> Result<mpsc::Receiver<StreamEvent>> {
534+
self.send_streaming(
535+
self.build_chat_request(messages, system, tools, Some(directive)),
536+
cancel_token,
537+
)
538+
.await
539+
}
540+
}
549541

542+
impl OpenAiClient {
543+
/// Execute a fully-built streaming chat-completions request (sets `stream`).
544+
async fn send_streaming(
545+
&self,
546+
mut request: serde_json::Value,
547+
cancel_token: tokio_util::sync::CancellationToken,
548+
) -> Result<mpsc::Receiver<StreamEvent>> {
549+
{
550+
request["stream"] = serde_json::json!(true);
551+
request["stream_options"] = serde_json::json!({ "include_usage": true });
552+
let request_started_at = Instant::now();
550553
let url = format!("{}{}", self.base_url, self.chat_completions_path);
551554
let request_headers = self.request_headers();
552555

core/src/llm/structured_tests.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,52 @@ impl LlmClient for RecordingClient {
13991399
*self.last_tool_names.lock().unwrap() = tools.iter().map(|t| t.name.clone()).collect();
14001400
self.pop()
14011401
}
1402+
1403+
async fn complete_streaming_structured(
1404+
&self,
1405+
_messages: &[Message],
1406+
_system: Option<&str>,
1407+
tools: &[ToolDefinition],
1408+
directive: &StructuredDirective,
1409+
_cancel_token: CancellationToken,
1410+
) -> anyhow::Result<mpsc::Receiver<StreamEvent>> {
1411+
self.structured_calls
1412+
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1413+
*self.last_directive.lock().unwrap() = Some(directive.clone());
1414+
*self.last_tool_names.lock().unwrap() = tools.iter().map(|t| t.name.clone()).collect();
1415+
let response = self.pop()?;
1416+
let (tx, rx) = mpsc::channel(10);
1417+
tokio::spawn(async move {
1418+
for block in &response.message.content {
1419+
if let ContentBlock::ToolUse { name, input, .. } = block {
1420+
tx.send(StreamEvent::ToolUseStart {
1421+
id: "call_001".to_string(),
1422+
name: name.clone(),
1423+
})
1424+
.await
1425+
.ok();
1426+
let json_str = serde_json::to_string(input).unwrap();
1427+
for chunk in json_str.as_bytes().chunks(8) {
1428+
tx.send(StreamEvent::ToolUseInputDelta(
1429+
String::from_utf8_lossy(chunk).to_string(),
1430+
))
1431+
.await
1432+
.ok();
1433+
}
1434+
} else if let ContentBlock::Text { text } = block {
1435+
for chunk in text.as_bytes().chunks(8) {
1436+
tx.send(StreamEvent::TextDelta(
1437+
String::from_utf8_lossy(chunk).to_string(),
1438+
))
1439+
.await
1440+
.ok();
1441+
}
1442+
}
1443+
}
1444+
tx.send(StreamEvent::Done(response)).await.ok();
1445+
});
1446+
Ok(rx)
1447+
}
14021448
}
14031449

14041450
fn person_request(mode: StructuredMode) -> StructuredRequest {
@@ -1533,6 +1579,40 @@ async fn test_routing_json_uses_json_object_when_supported() {
15331579
assert!(directive.force_tool.is_none());
15341580
}
15351581

1582+
#[tokio::test]
1583+
async fn test_streaming_routing_tool_mode_forces_tool_choice() {
1584+
// The streaming path must also force the directive (via
1585+
// complete_streaming_structured), not silently drop it.
1586+
let client = RecordingClient::new(
1587+
NativeStructuredSupport::ForcedTool,
1588+
vec![MockStructuredClient::tool_call_response(
1589+
"emit_person",
1590+
serde_json::json!({ "name": "Bob" }),
1591+
)],
1592+
);
1593+
1594+
let partials = Arc::new(Mutex::new(0usize));
1595+
let partials_cb = partials.clone();
1596+
let result = generate_streaming(
1597+
&client,
1598+
&person_request(StructuredMode::Tool),
1599+
Box::new(move |_partial| {
1600+
*partials_cb.lock().unwrap() += 1;
1601+
}),
1602+
)
1603+
.await
1604+
.unwrap();
1605+
1606+
assert_eq!(result.object["name"], "Bob");
1607+
assert_eq!(result.mode_used, StructuredMode::Tool);
1608+
let directive = client.last_directive.lock().unwrap().clone().unwrap();
1609+
assert_eq!(directive.force_tool.as_deref(), Some("emit_person"));
1610+
assert!(
1611+
*partials.lock().unwrap() >= 1,
1612+
"on_partial should fire at least once (final object)"
1613+
);
1614+
}
1615+
15361616
// ========================================================================
15371617
// Adversarial JSON extraction edge cases
15381618
// ========================================================================

core/tests/test_structured_json_real_llm.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ use std::time::Duration;
2222

2323
use a3s_code_core::config::CodeConfig;
2424
use a3s_code_core::llm::structured::{
25-
generate_blocking, StructuredMode, StructuredRequest, StructuredResult,
25+
generate_blocking, generate_streaming, PartialObjectCallback, StructuredMode,
26+
StructuredRequest, StructuredResult,
2627
};
2728
use a3s_code_core::llm::{create_client_with_config, LlmClient};
2829
use a3s_code_core::planning::LlmPlanner;
@@ -162,6 +163,39 @@ async fn real_structured_tool_mode_is_stable() {
162163
);
163164
}
164165

166+
/// The streaming structured path must also force `tool_choice` and yield a
167+
/// valid object (the streaming counterpart of the core fix).
168+
#[tokio::test(flavor = "multi_thread")]
169+
#[ignore = "requires real provider credentials and network access"]
170+
async fn real_structured_tool_mode_streaming() {
171+
let client = real_client();
172+
let partials = std::sync::Arc::new(std::sync::Mutex::new(0usize));
173+
let partials_cb = partials.clone();
174+
let on_partial: PartialObjectCallback = Box::new(move |_p| {
175+
*partials_cb.lock().unwrap() += 1;
176+
});
177+
178+
let result = tokio::time::timeout(
179+
CALL_TIMEOUT,
180+
generate_streaming(
181+
client.as_ref(),
182+
&person_request(StructuredMode::Tool),
183+
on_partial,
184+
),
185+
)
186+
.await
187+
.expect("streaming call timed out")
188+
.expect("streaming tool-mode generation failed");
189+
190+
assert_eq!(result.mode_used, StructuredMode::Tool);
191+
assert_valid_person(&result.object);
192+
eprintln!(
193+
"[tool-stream] partials={} -> {}",
194+
*partials.lock().unwrap(),
195+
result.object
196+
);
197+
}
198+
165199
/// Native `response_format: json_object` on the OpenAI-compatible provider.
166200
#[tokio::test(flavor = "multi_thread")]
167201
#[ignore = "requires real provider credentials and network access"]

0 commit comments

Comments
 (0)