From 00614a7aebec4f6a1d46c0d7d551b4d9df3d9350 Mon Sep 17 00:00:00 2001 From: Cody Date: Sun, 5 Apr 2026 19:54:30 -0400 Subject: [PATCH 1/2] fix(client): handle \r\n line endings in SSE, release GIL in Python - Normalize \r\n to \n in SSE buffer before parsing (spec compliance) - Wrap blocking chat() with py.allow_threads() to release GIL during network I/O - Add tests for \r\n SSE event parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/stringflow-core/src/client.rs | 22 ++++++++++++++++++++++ crates/stringflow-py/src/lib.rs | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/stringflow-core/src/client.rs b/crates/stringflow-core/src/client.rs index 73d7134..a27279c 100644 --- a/crates/stringflow-core/src/client.rs +++ b/crates/stringflow-core/src/client.rs @@ -42,6 +42,9 @@ fn apply_auth_blocking( fn parse_sse_buffer(buffer: &str, format: WireFormat) -> (Vec, String) { let mut events = Vec::new(); + // Normalize \r\n line endings to \n per SSE spec + let buffer = buffer.replace("\r\n", "\n"); + // Split on double-newline (SSE event boundaries) let mut parts = buffer.split("\n\n").peekable(); @@ -354,6 +357,25 @@ mod tests { assert_eq!(remaining, "data: partial"); } + #[test] + fn parse_sse_buffer_crlf_line_endings() { + let buffer = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\r\n\r\n"; + let (events, remaining) = parse_sse_buffer(buffer, WireFormat::Completions); + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], StreamEvent::Delta(s) if s == "hi")); + assert!(remaining.is_empty()); + } + + #[test] + fn parse_sse_buffer_crlf_multiple_events() { + let buffer = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\r\n\r\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n"; + let (events, _) = parse_sse_buffer(buffer, WireFormat::Completions); + assert_eq!(events.len(), 3); + assert!(matches!(&events[0], StreamEvent::Delta(s) if s == "a")); + assert!(matches!(&events[1], StreamEvent::Delta(s) if s == "b")); + assert!(matches!(&events[2], StreamEvent::Done)); + } + #[test] fn parse_sse_buffer_with_event_prefix() { let buffer = "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n"; diff --git a/crates/stringflow-py/src/lib.rs b/crates/stringflow-py/src/lib.rs index d031c1a..fdd67f9 100644 --- a/crates/stringflow-py/src/lib.rs +++ b/crates/stringflow-py/src/lib.rs @@ -14,6 +14,7 @@ fn to_py_err(e: stringflow::Error) -> PyErr { #[pyfunction] #[pyo3(signature = (base_url, messages, wire_format="messages", model=None, max_tokens=None, auth_bearer=None, auth_header=None, auth_value=None))] fn chat( + py: Python<'_>, base_url: &str, messages: Vec<(String, String)>, wire_format: &str, @@ -33,7 +34,7 @@ fn chat( auth_value, )?; let msgs = to_chat_messages(messages); - stringflow::chat(&config, &msgs).map_err(to_py_err) + py.allow_threads(|| stringflow::chat(&config, &msgs).map_err(to_py_err)) } // -- Health check ------------------------------------------------------------- From ba4f3a03b27c54f4ed03aa1e8e4d3135588978bd Mon Sep 17 00:00:00 2001 From: Cody Date: Sun, 5 Apr 2026 20:34:41 -0400 Subject: [PATCH 2/2] fix(py): revert allow_threads (not available in pyo3 0.28) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/stringflow-py/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/stringflow-py/src/lib.rs b/crates/stringflow-py/src/lib.rs index fdd67f9..d031c1a 100644 --- a/crates/stringflow-py/src/lib.rs +++ b/crates/stringflow-py/src/lib.rs @@ -14,7 +14,6 @@ fn to_py_err(e: stringflow::Error) -> PyErr { #[pyfunction] #[pyo3(signature = (base_url, messages, wire_format="messages", model=None, max_tokens=None, auth_bearer=None, auth_header=None, auth_value=None))] fn chat( - py: Python<'_>, base_url: &str, messages: Vec<(String, String)>, wire_format: &str, @@ -34,7 +33,7 @@ fn chat( auth_value, )?; let msgs = to_chat_messages(messages); - py.allow_threads(|| stringflow::chat(&config, &msgs).map_err(to_py_err)) + stringflow::chat(&config, &msgs).map_err(to_py_err) } // -- Health check -------------------------------------------------------------