Skip to content
Merged
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
50 changes: 46 additions & 4 deletions crates/tinyinference-llm/src/providers/openai/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,16 @@ pub(super) fn parse_chat_response(
}
}

// Thinking parts of a list `content` follow the side-channel reasoning.
let (text, content_reasoning) = choice
.message
.content
.map(split_response_content)
.unwrap_or_default();
reasoning.push_str(&content_reasoning);

// Inline `<think>` extraction on the visible content, when enabled.
let visible = match (
choice.message.content.filter(|t| !t.is_empty()),
reasoning_tags,
) {
let visible = match (Some(text).filter(|t| !t.is_empty()), reasoning_tags) {
(Some(text), Some(config)) => {
let (visible, inline) = extract_reasoning(config, &text);
if !inline.is_empty() {
Expand Down Expand Up @@ -644,6 +649,43 @@ pub(super) fn reasoning_value_text(value: Value) -> Option<String> {
}
}

/// Splits a response or delta `content` into `(visible text, reasoning)`.
///
/// A string is all visible text. In a list of parts, `text` parts are visible
/// text, concatenated in order; `thinking` / `reasoning` parts are reasoning,
/// read from their `thinking` field (a string or a list of text parts) or else
/// their `text` field, and normalized like `reasoning_content`. Parts of any
/// other type, and anything that is not an object, are skipped.
pub(super) fn split_response_content(content: ResponseContentWire) -> (String, String) {
let parts = match content {
ResponseContentWire::Text(text) => return (text, String::new()),
ResponseContentWire::Parts(parts) => parts,
};
let mut text = String::new();
let mut reasoning = String::new();
for part in &parts {
match part.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(fragment) = part.get("text").and_then(Value::as_str) {
text.push_str(fragment);
}
}
Some("thinking" | "reasoning") => {
if let Some(fragment) = part
.get("thinking")
.or_else(|| part.get("text"))
.cloned()
.and_then(reasoning_value_text)
{
reasoning.push_str(&fragment);
}
}
_ => {}
}
}
(text, reasoning)
}

/// Extracts the reasoning/thinking text from a streamed delta, accepting the
/// common OpenAI-compatible aliases.
pub(super) fn delta_reasoning_text(delta: &mut ChunkDeltaWire) -> String {
Expand Down
36 changes: 35 additions & 1 deletion crates/tinyinference-llm/src/providers/openai/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,37 @@ fn parse_chat_response_without_config_leaves_inline_tags_in_text() {
assert_eq!(response_reasoning(&response), "");
}

/// A thinking model behind an OpenAI-compatible gateway returns the message
/// `content` as a list of typed parts. Text parts are the answer; thinking
/// parts are reasoning, after any side-channel reasoning, and never visible.
#[test]
fn parse_chat_response_reads_list_content_parts() {
let body = json!({
"id": "chatcmpl-list",
"choices": [{
"message": {
"role": "assistant",
"reasoning_content": "side ",
"content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": "plan" }], "closed": true },
{ "type": "text", "text": "{\"reason\":" },
{ "type": "unknown_part", "data": 1 },
{ "type": "text", "text": "\"ok\"}" }
]
},
"finish_reason": "stop"
}]
});

let response = parse_chat_response(body, None).unwrap();
assert_eq!(response.text(), "{\"reason\":\"ok\"}");
assert_eq!(response_reasoning(&response), "side plan");
assert!(matches!(
response.message.content.as_slice(),
[ContentBlock::Thinking { .. }, ContentBlock::Text(_)]
));
}

#[tokio::test]
async fn sse_stream_reassembles_multibyte_char_split_across_chunks() {
// A 4-byte emoji in the content payload, split down the middle across two
Expand Down Expand Up @@ -3035,7 +3066,10 @@ fn a_null_tool_calls_delta_is_read_as_no_fragments() {
}))
.expect("a null tool_calls must not fail the chunk");
assert!(chunk.choices[0].delta.tool_calls.is_empty());
assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hi"));
assert_eq!(
chunk.choices[0].delta.content,
Some(ResponseContentWire::Text("Hi".into()))
);
}

/// Unwraps a [`ModelStreamItem::BlockStart`], panicking with the actual item
Expand Down
48 changes: 45 additions & 3 deletions crates/tinyinference-llm/src/providers/openai/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,17 @@ impl OpenAiStreamAcc {
if let Some(reason) = choice.finish_reason {
self.finish_reason = Some(reason);
}
let reasoning = delta_reasoning_text(&mut choice.delta);
// A list `content` can carry thinking and text parts in one chunk;
// its reasoning streams before its text, after any side-channel
// reasoning, so the block order stays thinking -> text.
let (content, content_reasoning) = choice
.delta
.content
.take()
.map(split_response_content)
.unwrap_or_default();
let mut reasoning = delta_reasoning_text(&mut choice.delta);
reasoning.push_str(&content_reasoning);
if !reasoning.is_empty() {
self.reasoning.push_str(&reasoning);
self.push_block_delta(BlockRequest::Reasoning, &reasoning, pending);
Expand All @@ -246,7 +256,7 @@ impl OpenAiStreamAcc {
tool_call: None,
}));
}
if let Some(content) = choice.delta.content.filter(|c| !c.is_empty()) {
if !content.is_empty() {
// Retain the raw content so the terminal response can recompute
// an authoritative inline-tag split (see `into_response`).
self.text.push_str(&content);
Expand Down Expand Up @@ -710,7 +720,27 @@ impl SseState {
self.acc.keep_usage_extensions(usage);
}
}
if let Ok(chunk) = serde_json::from_value::<ChatCompletionChunk>(value) {
let chunk = match serde_json::from_value::<ChatCompletionChunk>(value) {
Ok(chunk) => Some(chunk),
Err(error) => {
// Still skipped, but never silently: a dropped chunk loses
// whatever text it carried, and a silent drop hides that the
// answer arrived truncated. The payload can hold answer or
// reasoning text, so only DEBUG carries it, truncated.
tracing::warn!(
provider = %self.provider,
model = %self.model,
%error,
"openai sse: skipped a chunk that does not match the chunk schema"
);
tracing::debug!(
payload = %log_preview(payload),
"openai sse: skipped chunk payload"
);
None
}
};
if let Some(chunk) = chunk {
// `resolve_slot` grows the slot vector up to the wire index, so an
// out-of-range index is rejected before it can size an allocation.
if let Some(index) = chunk
Expand Down Expand Up @@ -805,6 +835,18 @@ impl SseState {
}
}

/// Characters of an SSE payload kept when one is logged.
const LOG_PREVIEW_CHARS: usize = 512;

/// The first [`LOG_PREVIEW_CHARS`] characters of `payload` (UTF-8 safe),
/// marked with `...` when cut, so a log line stays bounded.
fn log_preview(payload: &str) -> String {
match payload.char_indices().nth(LOG_PREVIEW_CHARS) {
Some((end, _)) => format!("{}...", &payload[..end]),
None => payload.to_string(),
}
}

/// Advances the SSE [`SseState`] by one item for [`futures::stream::unfold`].
pub(super) async fn sse_next(mut state: SseState) -> Option<(ModelStreamItem, SseState)> {
loop {
Expand Down
Loading
Loading