use choreo_ai_protocols::openai::{
AssistantToolCall, AssistantToolFunction, ChatImagePart, ChatRequestMessage,
};
use choreo_ai_protocols::{
ReasoningPassback, model_reasoning_passback, model_supports_vision, requires_reasoning_content,
};
use choreo_proto::{ReasoningArtifact, Turn};
use tracing::{debug, warn};
use crate::sessions::SessionState;
pub fn build_chat_request_messages(
session: &SessionState,
system_prompt: Option<&str>,
provider_slug: &str,
model: &str,
) -> Vec<ChatRequestMessage> {
let mut messages = Vec::new();
if let Some(prompt) = system_prompt {
messages.push(ChatRequestMessage::simple("system", prompt.to_string()));
}
let passback = model_reasoning_passback(provider_slug, model);
let requires_rc = requires_reasoning_content(provider_slug, model);
let vision = model_supports_vision(provider_slug, model);
for turn in session.turns.values() {
if turn.undone {
continue;
}
if let Some(text) = &turn.user_text {
messages.push(ChatRequestMessage::simple("user", text.clone()));
}
let include_artifact = include_reasoning_artifact(turn, provider_slug, model, passback);
let has_tool_calls = !turn.tool_calls.is_empty();
if turn.assistant_text.is_some() || has_tool_calls {
let tool_calls = if has_tool_calls {
Some(
turn.tool_calls
.iter()
.map(|tc| AssistantToolCall {
id: tc.call_id.clone(),
kind: "function".to_string(),
function: AssistantToolFunction {
name: tc.name.clone(),
arguments: tc.arguments_json.clone(),
},
})
.collect(),
)
} else {
None
};
messages.push(ChatRequestMessage {
role: "assistant",
content: turn.assistant_text.clone(),
images: Vec::new(),
tool_call_id: None,
tool_calls,
reasoning_content: if requires_rc && !include_artifact {
Some(String::new())
} else {
None
},
reasoning: None,
reasoning_text: None,
reasoning_artifact: if include_artifact {
turn.reasoning_artifact.clone()
} else {
None
},
});
}
for tr in &turn.tool_results {
messages.push(ChatRequestMessage {
role: "tool",
content: Some(tr.content.clone()),
images: Vec::new(),
tool_call_id: Some(tr.call_id.clone()),
tool_calls: None,
reasoning_content: None,
reasoning: None,
reasoning_text: None,
reasoning_artifact: None,
});
}
messages.extend(tool_result_image_messages(turn, vision));
}
messages
}
fn tool_result_image_messages(turn: &Turn, vision: bool) -> Vec<ChatRequestMessage> {
let mut out = Vec::new();
for tr in &turn.tool_results {
let Some(img) = &tr.image else {
continue;
};
let lead = format!("[image from `{}` tool result: {}]", tr.name, img.path);
if !vision {
out.push(ChatRequestMessage::with_images(
"user",
format!(
"{lead} — the active model does not support image input, so the image \
could not be attached; its metadata is in the tool result above."
),
Vec::new(),
));
continue;
}
if img.data.is_empty() {
warn!(
path = %img.path,
"tool-result image bytes unavailable; attaching placeholder"
);
out.push(ChatRequestMessage::with_images(
"user",
format!(
"{lead} — the image bytes are unavailable (e.g. an old persisted turn); \
only its text metadata is available."
),
Vec::new(),
));
} else {
debug!(
path = %img.path,
mime = %img.mime_type,
bytes = img.data.len(),
"attaching stored tool-result image to request"
);
out.push(ChatRequestMessage::with_images(
"user",
lead,
vec![ChatImagePart {
data: img.data.clone(),
mime_type: img.mime_type.clone(),
}],
));
}
}
out
}
fn turn_has_tool_involvement(turn: &Turn) -> bool {
!turn.tool_calls.is_empty() || !turn.tool_results.is_empty()
}
pub(crate) fn include_reasoning_artifact(
turn: &Turn,
provider_slug: &str,
model: &str,
passback: ReasoningPassback,
) -> bool {
let same_model = turn
.reasoning_producer
.as_ref()
.map(|p| (p.provider_slug.as_str(), p.model.as_str()))
== Some((provider_slug, model));
if !same_model || turn.reasoning_artifact.is_none() {
return false;
}
let policy_echo = match passback {
ReasoningPassback::None | ReasoningPassback::ResponseId => false,
ReasoningPassback::ToolLoop => turn_has_tool_involvement(turn),
ReasoningPassback::AllTurns | ReasoningPassback::Signature => true,
};
let can_echo = !matches!(
passback,
ReasoningPassback::None | ReasoningPassback::ResponseId
);
policy_echo || (can_echo && turn_is_wire_empty(turn))
}
fn turn_is_wire_empty(turn: &Turn) -> bool {
turn.tool_calls.is_empty() && turn.assistant_text.as_deref().is_none_or(str::is_empty)
}
pub(crate) fn initial_prev_resp_id(
session: &SessionState,
provider_slug: &str,
model: &str,
) -> Option<String> {
let same_producer = session
.config
.last_response_id_producer
.as_ref()
.map(|p| (p.provider_slug.as_str(), p.model.as_str()))
== Some((provider_slug, model));
if same_producer
&& model_reasoning_passback(provider_slug, model) == ReasoningPassback::ResponseId
{
session.config.last_response_id.clone()
} else {
None
}
}
pub(crate) fn warn_on_missing_reasoning_artifacts(
session: &SessionState,
session_id: u64,
provider_slug: &str,
model: &str,
) -> usize {
let passback = model_reasoning_passback(provider_slug, model);
if matches!(
passback,
ReasoningPassback::None | ReasoningPassback::ResponseId
) {
return 0;
}
let requires_rc = requires_reasoning_content(provider_slug, model);
let check_all_turns = matches!(
passback,
ReasoningPassback::AllTurns | ReasoningPassback::Signature
);
let mut problems = 0;
for (turn_id, turn) in session.turns.iter() {
if turn.undone {
continue;
}
let has_assistant_message = turn.assistant_text.is_some() || !turn.tool_calls.is_empty();
if !has_assistant_message {
continue;
}
if include_reasoning_artifact(turn, provider_slug, model, passback) {
continue;
}
let wire_empty = turn_is_wire_empty(turn);
let policy_demands_echo = check_all_turns || turn_has_tool_involvement(turn);
if !policy_demands_echo && !wire_empty {
continue;
}
problems += 1;
match (&turn.reasoning_artifact, turn.reasoning_producer.as_ref()) {
(None, _) => {
warn!(
session_id,
turn_id,
provider_slug,
model,
passback = ?passback,
requires_rc,
wire_empty,
"reasoning artifact missing for turn; provider may reject this request",
);
}
(Some(_), None) => {
warn!(
session_id,
turn_id,
provider_slug,
model,
passback = ?passback,
requires_rc,
wire_empty,
"reasoning artifact present but its producer is unrecorded; it will not be replayed and the provider may reject this request",
);
}
(Some(_), Some(_)) => {
warn!(
session_id,
turn_id,
provider_slug,
model,
passback = ?passback,
requires_rc,
wire_empty,
"reasoning artifact produced by a different model; it will not be replayed and the provider may reject this request",
);
}
}
}
problems
}
pub(crate) fn reasoning_artifact_tokens(
enc: &tiktoken::CoreBpe,
artifact: &ReasoningArtifact,
) -> u32 {
let bytes = match artifact {
ReasoningArtifact::ChatReasoning { bytes: b, .. }
| ReasoningArtifact::AnthropicThinking(b)
| ReasoningArtifact::GoogleSignatures(b)
| ReasoningArtifact::ResponsesItems(b) => b,
};
match std::str::from_utf8(bytes) {
Ok(text) => enc.count(text) as u32,
Err(_) => (bytes.len() / 4) as u32,
}
}