1104 lines
41 KiB
Rust
1104 lines
41 KiB
Rust
//! SSE stream generators for mock inference endpoints.
|
|
//!
|
|
//! These produce the exact wire format that the grok sampling client expects,
|
|
//! validated against the real sampling client.
|
|
|
|
use axum::response::sse::Event;
|
|
use serde_json::json;
|
|
|
|
use crate::scripted::SseEvent;
|
|
|
|
/// Generate Anthropic Messages SSE events: one text block streamed as a
|
|
/// single delta, terminated by a `message_delta` carrying `stop_reason`.
|
|
pub fn messages_api_events(text: &str, model: &str, stop_reason: &str) -> Vec<Event> {
|
|
vec![
|
|
Event::default().data(
|
|
json!({
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_test", "type": "message", "role": "assistant",
|
|
"content": [], "model": model, "stop_reason": null,
|
|
"usage": {
|
|
"input_tokens": 10, "output_tokens": 0,
|
|
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 0
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
Event::default().data(
|
|
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})
|
|
.to_string(),
|
|
),
|
|
Event::default().data(
|
|
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":text}})
|
|
.to_string(),
|
|
),
|
|
Event::default().data(json!({"type":"content_block_stop","index":0}).to_string()),
|
|
Event::default().data(
|
|
json!({"type":"message_delta","delta":{"stop_reason":stop_reason},"usage":{"output_tokens":5,"input_tokens":10}})
|
|
.to_string(),
|
|
),
|
|
Event::default().data(json!({"type":"message_stop"}).to_string()),
|
|
]
|
|
}
|
|
|
|
/// Generate ChatCompletions SSE events that stream `text` word-by-word
|
|
/// (whitespace-collapsing; use [`chat_completion_events_exact`] when the
|
|
/// receiver must reconstruct `text` byte-for-byte).
|
|
pub fn chat_completion_events(text: &str, model: &str) -> Vec<Event> {
|
|
chat_completion_events_from_deltas(&space_prefixed_deltas(text.split_whitespace()), model)
|
|
}
|
|
|
|
/// Like [`chat_completion_events`] but byte-exact: concatenating the deltas
|
|
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
|
|
/// Fenced code blocks (mermaid etc.) need their newlines to parse as a block,
|
|
/// which `split_whitespace` would destroy.
|
|
pub fn chat_completion_events_exact(text: &str, model: &str) -> Vec<Event> {
|
|
chat_completion_events_from_deltas(&chat_completion_deltas(text), model)
|
|
}
|
|
|
|
/// Split `text` into deltas that reconstruct it byte-for-byte: the first
|
|
/// carries no leading space; each subsequent one is ` {word}` (split on
|
|
/// single spaces only, so newlines/tabs stay inside the words).
|
|
fn chat_completion_deltas(text: &str) -> Vec<String> {
|
|
space_prefixed_deltas(text.split(' '))
|
|
}
|
|
|
|
/// Shape words into chat deltas: first word bare, each subsequent one ` {word}`
|
|
/// — the source iterator decides collapsing (echo) vs byte-exact (fixed).
|
|
fn space_prefixed_deltas<'a>(words: impl Iterator<Item = &'a str>) -> Vec<String> {
|
|
words
|
|
.enumerate()
|
|
.map(|(i, word)| {
|
|
if i == 0 {
|
|
word.to_owned()
|
|
} else {
|
|
format!(" {word}")
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Event> {
|
|
let n = deltas.len();
|
|
let mut events = Vec::new();
|
|
|
|
for (i, content) in deltas.iter().enumerate() {
|
|
let finish_reason = if i + 1 == n {
|
|
json!("stop")
|
|
} else {
|
|
json!(null)
|
|
};
|
|
|
|
let chunk = if i == 0 {
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": { "role": "assistant", "content": content },
|
|
"finish_reason": finish_reason
|
|
}]
|
|
})
|
|
} else {
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": { "content": content },
|
|
"finish_reason": finish_reason
|
|
}]
|
|
})
|
|
};
|
|
events.push(Event::default().data(chunk.to_string()));
|
|
}
|
|
|
|
// Usage chunk
|
|
events.push(
|
|
Event::default().data(
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": n,
|
|
"total_tokens": 10 + n
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
);
|
|
events.push(Event::default().data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
/// Generate Responses API SSE events that stream `text` word-by-word
|
|
/// (whitespace-collapsing; use [`responses_api_events_exact`] when the
|
|
/// receiver must reconstruct `text` byte-for-byte).
|
|
pub fn responses_api_events(text: &str, model: &str) -> Vec<Event> {
|
|
let deltas: Vec<String> = text
|
|
.split_whitespace()
|
|
.map(|word| format!("{word} "))
|
|
.collect();
|
|
responses_api_events_from_deltas(&deltas, text, model)
|
|
}
|
|
|
|
/// Like [`responses_api_events`] but byte-exact: concatenating the deltas
|
|
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
|
|
pub fn responses_api_events_exact(text: &str, model: &str) -> Vec<Event> {
|
|
responses_api_events_from_deltas(&responses_api_deltas(text), text, model)
|
|
}
|
|
|
|
/// `split_inclusive(' ')` keeps each chunk's trailing space, so concatenating
|
|
/// the chunks reconstructs `text` byte-for-byte (newlines included).
|
|
fn responses_api_deltas(text: &str) -> Vec<String> {
|
|
text.split_inclusive(' ').map(str::to_owned).collect()
|
|
}
|
|
|
|
// `deltas` and `text` deliberately disagree in echo mode: collapsed deltas, uncollapsed
|
|
// `response.completed` text — inherited load-bearing shell behavior, do not unify.
|
|
fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str) -> Vec<Event> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0;
|
|
|
|
// response.created
|
|
events.push(
|
|
Event::default().data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
);
|
|
seq += 1;
|
|
|
|
// Text deltas
|
|
for chunk in deltas {
|
|
events.push(
|
|
Event::default().data(
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"sequence_number": seq,
|
|
"item_id": "item_test",
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
"delta": chunk
|
|
})
|
|
.to_string(),
|
|
),
|
|
);
|
|
seq += 1;
|
|
}
|
|
|
|
// response.completed
|
|
events.push(
|
|
Event::default().data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "message",
|
|
"id": "msg_test",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": text,
|
|
"annotations": []
|
|
}]
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 0 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
);
|
|
events.push(Event::default().data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
/// Generate Responses API SSE events for a reasoning-only completion: the
|
|
/// model streams reasoning summary deltas and finishes with a `reasoning`
|
|
/// output item but NO message / output-text and no tool call. The shell's
|
|
/// collector synthesizes an empty assistant, so the response classifies as
|
|
/// `EmptyReason::ReasoningOnly` — the trigger that makes the sampler resample
|
|
/// (the model doomloop).
|
|
///
|
|
/// Returns [`SseEvent`]s (not axum `Event`s) for direct use with
|
|
/// [`crate::ScriptedResponse::sse`] / `enqueue_response`: reasoning-only is a
|
|
/// scripted scenario, not an echo/fixed response mode, so it is not wired into
|
|
/// the `mock_server` mode handlers.
|
|
pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0;
|
|
|
|
// response.created
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
|
|
// Reasoning summary deltas — the only content the model streams.
|
|
for word in reasoning.split_whitespace() {
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"sequence_number": seq,
|
|
"item_id": "reasoning_item_1",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": format!("{word} ")
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
}
|
|
|
|
// response.completed: a single `reasoning` output item carrying the full
|
|
// summary and NO message item. `response_to_conversation_items` appends an
|
|
// empty assistant, yielding `[Reasoning, Assistant("")]` → reasoning_only.
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "reasoning",
|
|
"id": "reasoning_item_1",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"text": reasoning
|
|
}],
|
|
"status": "completed"
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 5 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
/// Generate Responses API SSE events for a completion that streams reasoning
|
|
/// summary deltas FIRST and then a normal text answer: the shape a
|
|
/// reasoning-capable model produces on an ordinary turn. `response.completed`
|
|
/// carries both output items (`reasoning` + `message`), so the collector
|
|
/// yields `[Reasoning, Assistant(text)]` — a full, non-empty turn.
|
|
///
|
|
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
|
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
|
pub fn responses_api_reasoning_and_text_events(
|
|
reasoning: &str,
|
|
text: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0;
|
|
|
|
// response.created
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
|
|
// Reasoning summary deltas stream before any answer text.
|
|
for word in reasoning.split_whitespace() {
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"sequence_number": seq,
|
|
"item_id": "reasoning_item_1",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": format!("{word} ")
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
}
|
|
|
|
// Then the visible answer.
|
|
for word in text.split_whitespace() {
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"sequence_number": seq,
|
|
"item_id": "item_test",
|
|
"output_index": 1,
|
|
"content_index": 0,
|
|
"delta": format!("{word} ")
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
}
|
|
|
|
// response.completed with BOTH items: reasoning + the assistant message.
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"type": "reasoning",
|
|
"id": "reasoning_item_1",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"text": reasoning
|
|
}],
|
|
"status": "completed"
|
|
},
|
|
{
|
|
"type": "message",
|
|
"id": "msg_test",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": text,
|
|
"annotations": []
|
|
}]
|
|
}
|
|
],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 10,
|
|
"total_tokens": 20,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 5 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
/// SSE `event:` name and payload `type` of the non-standard doom-loop check
|
|
/// event (`xai_grok_sampling_types::DOOM_LOOP_CHECK_EVENT_TYPE`). Hardcoded
|
|
/// like every other wire string in this file; the shell integration tests
|
|
/// pin the two spellings against each other by absorbing built frames
|
|
/// through the real client.
|
|
const DOOM_LOOP_CHECK_EVENT: &str = "response.doom_loop_check";
|
|
|
|
/// One named `response.doom_loop_check` frame carrying the (cumulative)
|
|
/// trigger set, in the inference API's wire shape.
|
|
fn doom_loop_check_frame(triggers: &[&str], seq: u64) -> SseEvent {
|
|
SseEvent::with_event(
|
|
DOOM_LOOP_CHECK_EVENT,
|
|
json!({
|
|
"sequence_number": seq,
|
|
"type": DOOM_LOOP_CHECK_EVENT,
|
|
"doom_loop_check": { "triggers": triggers }
|
|
})
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
/// Inject `doom_loop_check.triggers` into a turn's terminal
|
|
/// `response.completed` object — the single home for terminal-field emission,
|
|
/// the dual of the mid-stream [`doom_loop_check_frame`]. Composes over any
|
|
/// turn builder (re-serialization may reorder JSON keys; clients and shape
|
|
/// tests parse, never byte-compare, these frames). Panics when the turn has
|
|
/// no completed frame: every builder emits one, so a miss is a script bug.
|
|
fn with_terminal_doom_loop_field(mut events: Vec<SseEvent>, triggers: &[&str]) -> Vec<SseEvent> {
|
|
let patched = events.iter_mut().any(|e| {
|
|
if e.data == "[DONE]" {
|
|
return false;
|
|
}
|
|
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&e.data) else {
|
|
return false;
|
|
};
|
|
if value["type"] != "response.completed" {
|
|
return false;
|
|
}
|
|
value["response"]["doom_loop_check"] = json!({ "triggers": triggers });
|
|
e.data = value.to_string();
|
|
true
|
|
});
|
|
assert!(
|
|
patched,
|
|
"turn builders always emit a response.completed frame"
|
|
);
|
|
events
|
|
}
|
|
|
|
/// Generate Responses API SSE events for a server-detected doom loop: a
|
|
/// reasoning-only stream (the doomed signature — the model loops in its
|
|
/// thinking and never answers) followed by named `response.doom_loop_check`
|
|
/// frames re-sent with the growing **cumulative** trigger set (one frame per
|
|
/// prefix of `triggers`, mirroring how the server re-emits as new triggers
|
|
/// appear), and a terminal `response.completed` whose response object carries
|
|
/// the full set under `doom_loop_check.triggers`.
|
|
///
|
|
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
|
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
|
pub fn responses_api_doom_loop_check_events(
|
|
triggers: &[&str],
|
|
reasoning: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = responses_api_reasoning_only_events(reasoning, model);
|
|
// Cumulative frames land between the deltas and the terminal event; the
|
|
// frame seq roughly continues the stream (clients never validate it).
|
|
for prefix_len in 1..=triggers.len() {
|
|
let at = events.len() - 2;
|
|
events.insert(
|
|
at,
|
|
doom_loop_check_frame(&triggers[..prefix_len], at as u64),
|
|
);
|
|
}
|
|
with_terminal_doom_loop_field(events, triggers)
|
|
}
|
|
|
|
/// Generate Responses API SSE events for an ordinary reasoning + text turn
|
|
/// (mirroring [`responses_api_reasoning_and_text_events`]) whose terminal
|
|
/// `response.completed` object carries `doom_loop_check.triggers` with NO
|
|
/// mid-stream check frame — the terminal-only copy of the signal.
|
|
pub fn responses_api_doom_loop_terminal_only_events(
|
|
triggers: &[&str],
|
|
reasoning: &str,
|
|
text: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
with_terminal_doom_loop_field(
|
|
responses_api_reasoning_and_text_events(reasoning, text, model),
|
|
triggers,
|
|
)
|
|
}
|
|
|
|
/// Splice ONE named `response.doom_loop_check` frame with an arbitrary
|
|
/// `data:` payload — a byte-exact wire fixture or a malformed variant — into
|
|
/// an otherwise-normal reasoning + text turn, right after `response.created`.
|
|
/// The payload's own `sequence_number` (if any) is its business; clients
|
|
/// never validate sequence continuity.
|
|
pub fn responses_api_with_doom_loop_frame(
|
|
check_frame_data: &str,
|
|
reasoning: &str,
|
|
text: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = responses_api_reasoning_and_text_events(reasoning, text, model);
|
|
events.insert(
|
|
1,
|
|
SseEvent::with_event(DOOM_LOOP_CHECK_EVENT, check_frame_data),
|
|
);
|
|
events
|
|
}
|
|
|
|
/// Generate Responses API SSE events for a turn that streams reasoning
|
|
/// summary deltas FIRST and then issues one `function_call` — the shape a
|
|
/// reasoning-capable model produces when it thinks before its first tool
|
|
/// call. `response.completed` carries both output items (`reasoning` +
|
|
/// `function_call`) and no message, so the collector yields
|
|
/// `[Reasoning, ToolCall]` (tool calls keep the turn non-empty — no
|
|
/// `EmptyReason::ReasoningOnly` resample).
|
|
///
|
|
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
|
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
|
pub fn responses_api_reasoning_then_tool_call_events(
|
|
reasoning: &str,
|
|
call_id: &str,
|
|
name: &str,
|
|
arguments: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0;
|
|
|
|
// response.created
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
|
|
// Reasoning summary deltas stream before the tool call.
|
|
for word in reasoning.split_whitespace() {
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"sequence_number": seq,
|
|
"item_id": "reasoning_item_1",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": format!("{word} ")
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
}
|
|
|
|
// Then the tool invocation.
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.function_call_arguments.delta",
|
|
"sequence_number": seq,
|
|
"item_id": call_id,
|
|
"output_index": 1,
|
|
"delta": arguments
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
|
|
// response.completed: the reasoning item plus the function_call item.
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": model,
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"type": "reasoning",
|
|
"id": "reasoning_item_1",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"text": reasoning
|
|
}],
|
|
"status": "completed"
|
|
},
|
|
{
|
|
"type": "function_call",
|
|
"call_id": call_id,
|
|
"name": name,
|
|
"arguments": arguments
|
|
}
|
|
],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 5 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
/// Chat Completions twin of [`responses_api_reasoning_then_tool_call_events`]:
|
|
/// `reasoning_content` deltas, then one `tool_calls` delta, then a
|
|
/// `finish_reason: "tool_calls"` chunk with usage.
|
|
pub fn chat_completions_reasoning_then_tool_call_events(
|
|
reasoning: &str,
|
|
call_id: &str,
|
|
name: &str,
|
|
arguments: &str,
|
|
model: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
for word in reasoning.split_whitespace() {
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": { "reasoning_content": format!("{word} ") },
|
|
"finish_reason": null
|
|
}]
|
|
})
|
|
.to_string(),
|
|
));
|
|
}
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"content": null,
|
|
"tool_calls": [{
|
|
"index": 0,
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": { "name": name, "arguments": arguments }
|
|
}]
|
|
},
|
|
"finish_reason": null
|
|
}]
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": model,
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {},
|
|
"finish_reason": "tool_calls"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 30
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]"));
|
|
events
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Both byte-exact delta encoders must reconstruct a multi-line response
|
|
/// (incl. a ```mermaid fence) byte-for-byte. This is load-bearing:
|
|
/// `split_whitespace` would collapse the fence's newlines onto one line,
|
|
/// so a client would never parse it as a code block and diagram detection
|
|
/// would silently fail.
|
|
#[test]
|
|
fn deltas_reconstruct_multiline_response_byte_for_byte() {
|
|
let text = "Here is a flow:\n\n```mermaid\nflowchart TD\n A --> B\n B --> C\n```\n\nDone rendering.\n";
|
|
|
|
assert_eq!(chat_completion_deltas(text).concat(), text);
|
|
assert_eq!(responses_api_deltas(text).concat(), text);
|
|
|
|
// The reconstruction preserves the fence as a real, newline-delimited
|
|
// code block (the property diagram detection depends on).
|
|
assert!(
|
|
chat_completion_deltas(text)
|
|
.concat()
|
|
.contains("```mermaid\nflowchart TD\n")
|
|
);
|
|
}
|
|
|
|
/// Multiple consecutive spaces and a trailing newline survive too (no
|
|
/// `split_whitespace`-style collapsing).
|
|
#[test]
|
|
fn deltas_preserve_runs_of_whitespace() {
|
|
let text = "a b\tc\n";
|
|
assert_eq!(chat_completion_deltas(text).concat(), text);
|
|
assert_eq!(responses_api_deltas(text).concat(), text);
|
|
}
|
|
|
|
/// Shape guard for the reasoning-only builder: parse each event back to JSON
|
|
/// and assert the structural tags/fields the shell collector keys on — at
|
|
/// least one `response.reasoning_summary_text.delta` carrying text, no
|
|
/// `response.output_text.delta`, and a `response.completed` whose output
|
|
/// holds a `reasoning` item (with summary text) and no `message` item,
|
|
/// terminated by `[DONE]`. A full round-trip through `rs::ResponseStreamEvent`
|
|
/// would pin the async-openai types directly, but that crate is not a
|
|
/// dependency here; the integration test deserializes these events through
|
|
/// the real client, covering the wire contract end-to-end.
|
|
#[test]
|
|
fn reasoning_only_events_carry_reasoning_and_no_output_text() {
|
|
let events = responses_api_reasoning_only_events("alpha beta gamma", "m");
|
|
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
|
|
|
// Parse every non-terminal event into JSON and key off the `type` tag.
|
|
let parsed: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
|
|
.collect();
|
|
let types: Vec<&str> = parsed
|
|
.iter()
|
|
.map(|v| v["type"].as_str().expect("each event has a type tag"))
|
|
.collect();
|
|
|
|
let reasoning_delta = parsed
|
|
.iter()
|
|
.find(|v| v["type"] == "response.reasoning_summary_text.delta")
|
|
.expect("must stream a reasoning summary delta");
|
|
assert!(
|
|
!reasoning_delta["delta"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.is_empty(),
|
|
"the reasoning delta must carry text"
|
|
);
|
|
assert!(
|
|
!types.contains(&"response.output_text.delta"),
|
|
"reasoning-only must not stream output text"
|
|
);
|
|
|
|
let completed = parsed
|
|
.iter()
|
|
.find(|v| v["type"] == "response.completed")
|
|
.expect("must emit a completed event");
|
|
let output = completed["response"]["output"]
|
|
.as_array()
|
|
.expect("completed carries an output array");
|
|
let reasoning_item = output
|
|
.iter()
|
|
.find(|o| o["type"] == "reasoning")
|
|
.expect("completed output must carry a reasoning item");
|
|
assert!(
|
|
!reasoning_item["summary"][0]["text"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.is_empty(),
|
|
"the reasoning item must carry summary text"
|
|
);
|
|
assert!(
|
|
!output.iter().any(|o| o["type"] == "message"),
|
|
"completed output must have no message item (no visible text)"
|
|
);
|
|
}
|
|
|
|
/// Shape guard for the reasoning+text builder: reasoning summary deltas
|
|
/// stream before the output-text deltas, and `response.completed` carries
|
|
/// BOTH a `reasoning` item and a `message` item, terminated by `[DONE]` —
|
|
/// the ordinary reasoning-model turn (never `EmptyReason::ReasoningOnly`).
|
|
#[test]
|
|
fn reasoning_and_text_events_carry_both_items() {
|
|
let events = responses_api_reasoning_and_text_events("alpha beta", "the answer", "m");
|
|
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
|
|
|
let parsed: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
|
|
.collect();
|
|
let types: Vec<&str> = parsed
|
|
.iter()
|
|
.map(|v| v["type"].as_str().expect("each event has a type tag"))
|
|
.collect();
|
|
|
|
// Reasoning streams strictly before the visible answer.
|
|
let first_reasoning = types
|
|
.iter()
|
|
.position(|t| *t == "response.reasoning_summary_text.delta")
|
|
.expect("must stream reasoning summary deltas");
|
|
let first_text = types
|
|
.iter()
|
|
.position(|t| *t == "response.output_text.delta")
|
|
.expect("must stream output text deltas");
|
|
assert!(
|
|
first_reasoning < first_text,
|
|
"reasoning deltas must precede text deltas"
|
|
);
|
|
|
|
let completed = parsed
|
|
.iter()
|
|
.find(|v| v["type"] == "response.completed")
|
|
.expect("must emit a completed event");
|
|
let output = completed["response"]["output"]
|
|
.as_array()
|
|
.expect("completed carries an output array");
|
|
assert_eq!(
|
|
output[0]["summary"][0]["text"].as_str(),
|
|
Some("alpha beta"),
|
|
"completed output must carry the reasoning item first"
|
|
);
|
|
assert_eq!(
|
|
output[1]["content"][0]["text"].as_str(),
|
|
Some("the answer"),
|
|
"completed output must carry the assistant message"
|
|
);
|
|
}
|
|
|
|
/// Shape guard for the reasoning+tool-call builder: reasoning summary
|
|
/// deltas stream before the function-call args delta, no output text
|
|
/// anywhere, and `response.completed` carries a `reasoning` item plus a
|
|
/// `function_call` item (no `message`), terminated by `[DONE]` — the
|
|
/// think-then-call turn whose tool call keeps it non-empty.
|
|
#[test]
|
|
fn reasoning_then_tool_call_events_carry_reasoning_and_function_call() {
|
|
let events = responses_api_reasoning_then_tool_call_events(
|
|
"alpha beta",
|
|
"call_1",
|
|
"read_file",
|
|
"{\"target_file\":\"a.rs\"}",
|
|
"m",
|
|
);
|
|
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
|
|
|
let parsed: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
|
|
.collect();
|
|
let types: Vec<&str> = parsed
|
|
.iter()
|
|
.map(|v| v["type"].as_str().expect("each event has a type tag"))
|
|
.collect();
|
|
|
|
// Reasoning streams strictly before the tool invocation; no text.
|
|
let first_reasoning = types
|
|
.iter()
|
|
.position(|t| *t == "response.reasoning_summary_text.delta")
|
|
.expect("must stream reasoning summary deltas");
|
|
let args_delta = types
|
|
.iter()
|
|
.position(|t| *t == "response.function_call_arguments.delta")
|
|
.expect("must stream a function-call arguments delta");
|
|
assert!(
|
|
first_reasoning < args_delta,
|
|
"reasoning deltas must precede the tool call"
|
|
);
|
|
assert!(
|
|
!types.contains(&"response.output_text.delta"),
|
|
"a think-then-call turn must not stream output text"
|
|
);
|
|
|
|
let completed = parsed
|
|
.iter()
|
|
.find(|v| v["type"] == "response.completed")
|
|
.expect("must emit a completed event");
|
|
let output = completed["response"]["output"]
|
|
.as_array()
|
|
.expect("completed carries an output array");
|
|
assert_eq!(
|
|
output[0]["summary"][0]["text"].as_str(),
|
|
Some("alpha beta"),
|
|
"completed output must carry the reasoning item first"
|
|
);
|
|
assert_eq!(output[1]["type"].as_str(), Some("function_call"));
|
|
assert_eq!(output[1]["call_id"].as_str(), Some("call_1"));
|
|
assert_eq!(output[1]["name"].as_str(), Some("read_file"));
|
|
assert!(
|
|
!output.iter().any(|o| o["type"] == "message"),
|
|
"completed output must have no message item (no visible text)"
|
|
);
|
|
}
|
|
|
|
/// Shape guard for the Chat Completions twin: `reasoning_content` deltas
|
|
/// stream first, then exactly one `tool_calls` delta carrying the call
|
|
/// id/name/arguments, then a `finish_reason: "tool_calls"` chunk, with no
|
|
/// visible `content` anywhere, terminated by `[DONE]`.
|
|
#[test]
|
|
fn chat_reasoning_then_tool_call_events_carry_reasoning_then_tool_call() {
|
|
let events = chat_completions_reasoning_then_tool_call_events(
|
|
"alpha beta",
|
|
"call_1",
|
|
"read_file",
|
|
"{\"target_file\":\"a.rs\"}",
|
|
"m",
|
|
);
|
|
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
|
|
|
let parsed: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
|
|
.collect();
|
|
let delta_at = |v: &serde_json::Value| v["choices"][0]["delta"].clone();
|
|
|
|
let first_reasoning = parsed
|
|
.iter()
|
|
.position(|v| !delta_at(v)["reasoning_content"].is_null())
|
|
.expect("must stream reasoning_content deltas");
|
|
let tool_call = parsed
|
|
.iter()
|
|
.position(|v| !delta_at(v)["tool_calls"].is_null())
|
|
.expect("must stream a tool_calls delta");
|
|
assert!(
|
|
first_reasoning < tool_call,
|
|
"reasoning deltas must precede the tool call"
|
|
);
|
|
let call = delta_at(&parsed[tool_call])["tool_calls"][0].clone();
|
|
assert_eq!(call["id"].as_str(), Some("call_1"));
|
|
assert_eq!(call["function"]["name"].as_str(), Some("read_file"));
|
|
assert!(
|
|
parsed.iter().all(|v| delta_at(v)["content"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.is_empty()),
|
|
"a think-then-call turn must not stream visible content"
|
|
);
|
|
assert!(
|
|
parsed
|
|
.iter()
|
|
.any(|v| v["choices"][0]["finish_reason"] == "tool_calls"),
|
|
"the stream must finish with finish_reason tool_calls"
|
|
);
|
|
}
|
|
|
|
/// Shape guard for the doom-loop builder: one NAMED check frame per
|
|
/// cumulative prefix of `triggers` (each frame re-sends every trigger so
|
|
/// far), a reasoning-only output (no message item — the doomed
|
|
/// signature), and the terminal response object carrying the full set.
|
|
#[test]
|
|
fn doom_loop_check_events_send_growing_named_frames_and_terminal_field() {
|
|
let events = responses_api_doom_loop_check_events(
|
|
&["tail_repetition:4@response", "tail_repetition:2@response"],
|
|
"looping thought",
|
|
"m",
|
|
);
|
|
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
|
|
|
let frames: Vec<&SseEvent> = events
|
|
.iter()
|
|
.filter(|e| e.event.as_deref() == Some(DOOM_LOOP_CHECK_EVENT))
|
|
.collect();
|
|
assert_eq!(frames.len(), 2, "one frame per cumulative prefix");
|
|
let first: serde_json::Value = serde_json::from_str(&frames[0].data).unwrap();
|
|
assert_eq!(first["type"], DOOM_LOOP_CHECK_EVENT);
|
|
assert!(first["sequence_number"].is_u64());
|
|
assert_eq!(
|
|
first["doom_loop_check"]["triggers"],
|
|
json!(["tail_repetition:4@response"])
|
|
);
|
|
let second: serde_json::Value = serde_json::from_str(&frames[1].data).unwrap();
|
|
assert_eq!(
|
|
second["doom_loop_check"]["triggers"],
|
|
json!(["tail_repetition:4@response", "tail_repetition:2@response"])
|
|
);
|
|
|
|
let completed = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str::<serde_json::Value>(&e.data).unwrap())
|
|
.find(|v| v["type"] == "response.completed")
|
|
.expect("must emit a completed event");
|
|
assert_eq!(
|
|
completed["response"]["doom_loop_check"]["triggers"],
|
|
json!(["tail_repetition:4@response", "tail_repetition:2@response"])
|
|
);
|
|
let output = completed["response"]["output"].as_array().unwrap();
|
|
assert!(
|
|
!output.iter().any(|o| o["type"] == "message"),
|
|
"a doomed turn is reasoning-only (no message item)"
|
|
);
|
|
}
|
|
|
|
/// Shape guard for the terminal-only variant: no named check frame
|
|
/// anywhere; the completed response carries both output items (the turn
|
|
/// is a normal answer) plus `doom_loop_check.triggers`.
|
|
#[test]
|
|
fn doom_loop_terminal_only_events_carry_field_without_mid_stream_frame() {
|
|
let events = responses_api_doom_loop_terminal_only_events(
|
|
&["low_logprob@thinking"],
|
|
"brief thought",
|
|
"the answer",
|
|
"m",
|
|
);
|
|
assert!(
|
|
events.iter().all(|e| e.event.is_none()),
|
|
"terminal-only variant must not emit a named check frame"
|
|
);
|
|
|
|
let completed = events
|
|
.iter()
|
|
.filter(|e| e.data != "[DONE]")
|
|
.map(|e| serde_json::from_str::<serde_json::Value>(&e.data).unwrap())
|
|
.find(|v| v["type"] == "response.completed")
|
|
.expect("must emit a completed event");
|
|
assert_eq!(
|
|
completed["response"]["doom_loop_check"]["triggers"],
|
|
json!(["low_logprob@thinking"])
|
|
);
|
|
let output = completed["response"]["output"].as_array().unwrap();
|
|
assert!(output.iter().any(|o| o["type"] == "message"));
|
|
assert!(output.iter().any(|o| o["type"] == "reasoning"));
|
|
}
|
|
|
|
/// Shape guard for the splice helper: the named frame lands right after
|
|
/// `response.created` with the caller's payload byte-for-byte (this is
|
|
/// how byte-exact fixtures and malformed variants ride a normal turn).
|
|
#[test]
|
|
fn with_doom_loop_frame_splices_payload_verbatim() {
|
|
let payload = r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":42}}"#;
|
|
let events = responses_api_with_doom_loop_frame(payload, "hm", "hi", "m");
|
|
assert_eq!(events[1].event.as_deref(), Some(DOOM_LOOP_CHECK_EVENT));
|
|
assert_eq!(events[1].data, payload);
|
|
let created: serde_json::Value = serde_json::from_str(&events[0].data).unwrap();
|
|
assert_eq!(created["type"], "response.created");
|
|
}
|
|
}
|