use super::{
AgentMessage, ContentBlock, Cost, ModelConfig, PartialTranslator, RawStreamEvent, StreamResult,
Usage, azure,
convert::{
BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX, COMPACTION_SUMMARY_PREFIX,
COMPACTION_SUMMARY_SUFFIX, collapse_mid_convo_system_messages, convert_system_message,
flush_pending_system_messages, instruction_role, sanitize_surrogates,
supports_mid_convo_system_messages, truncate_for_error,
},
provider_extra_headers, track_stream_bytes,
usage::{compute_cost, parse_streaming_json},
};
use crate::{
core::{
self, model_resolver,
provider::{apply_payload_hook, grammar_input_property_for_tool},
tools::tool_defs,
},
error::{Error, Result},
utils::{http, time::now_ms},
};
use futures_util::StreamExt;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::HashMap;
struct ConvertedToolResult {
tool: Value,
image_urls: Vec<String>,
}
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<Value>,
stream: bool,
#[serde(skip)]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enable_thinking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
chat_template_kwargs: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
fn normalize_tool_call_id(id: &str) -> String {
let sanitized: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
sanitized.chars().take(64).collect()
}
fn insert_synthetic_tool_results(params: &mut Vec<Value>, pending: &mut Vec<(String, String)>) {
for (id, _name) in pending.drain(..) {
params.push(json!({
"role": "tool",
"content": "No result provided",
"tool_call_id": id
}));
}
}
fn convert_user_message(msg: &AgentMessage, model: &ModelConfig) -> Option<Value> {
let supports_images = model.input.iter().any(|x| x == "image");
let mut content_arr: Vec<Value> = Vec::new();
let mut previous_was_placeholder = false;
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
if !text.is_empty() {
content_arr.push(json!({ "type": "text", "text": sanitize_surrogates(text) }));
}
previous_was_placeholder = text == "(image omitted: model does not support images)";
}
ContentBlock::Image { data, mime_type } => {
if supports_images {
content_arr.push(json!({
"type": "image_url",
"image_url": {
"url": format!("data:{};base64,{}", mime_type, data)
}
}));
previous_was_placeholder = false;
} else if !previous_was_placeholder {
content_arr.push(json!({
"type": "text",
"text": "(image omitted: model does not support images)"
}));
previous_was_placeholder = true;
}
}
_ => {}
}
}
if content_arr.is_empty() {
return None;
}
Some(json!({ "role": "user", "content": content_arr }))
}
fn convert_assistant_message(
msg: &AgentMessage,
model: &ModelConfig,
pending_tool_calls: &mut Vec<(String, String)>,
) -> Option<Value> {
if msg.stop_reason.as_deref() == Some("error") || msg.stop_reason.as_deref() == Some("aborted")
{
return None;
}
let assistant_text: String = msg
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } if !text.trim().is_empty() => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
let mut assistant_msg = serde_json::Map::new();
assistant_msg.insert("role".into(), json!("assistant"));
assistant_msg.insert("content".into(), Value::Null);
let non_empty_thinking: Vec<String> = msg
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Thinking { thinking, .. } if !thinking.trim().is_empty() => {
Some(thinking.clone())
}
_ => None,
})
.collect();
if !non_empty_thinking.is_empty() {
if !assistant_text.is_empty() {
assistant_msg.insert("content".into(), json!(assistant_text));
}
assistant_msg.insert(
"reasoning_content".into(),
json!(non_empty_thinking.join("\n")),
);
} else if !assistant_text.is_empty() {
assistant_msg.insert("content".into(), json!(assistant_text));
}
let tool_calls: Vec<&ContentBlock> = msg
.content
.iter()
.filter(|b| matches!(b, ContentBlock::ToolCall { .. }))
.collect();
if !tool_calls.is_empty() {
let grammar_supported =
model_resolver::supports_openai_grammar_tools(&model.provider, &model.model_id);
let calls: Vec<Value> = tool_calls
.iter()
.map(|b| match b {
ContentBlock::ToolCall { id, name, arguments, .. } => {
if grammar_supported
&& let Some(property) = grammar_input_property_for_tool(name)
{
return json!({
"id": normalize_tool_call_id(id),
"type": "custom",
"custom": {
"name": name,
"input": arguments
.get(&property)
.and_then(|v| v.as_str())
.unwrap_or_default(),
}
});
}
json!({
"id": normalize_tool_call_id(id),
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(arguments).unwrap_or_else(|_| "{}".into())
}
})
}
_ => Value::Null,
})
.collect();
assistant_msg.insert("tool_calls".into(), Value::Array(calls));
for tc in &tool_calls {
if let ContentBlock::ToolCall { id, name, .. } = tc {
pending_tool_calls.push((normalize_tool_call_id(id), name.clone()));
}
}
}
if model.requires_reasoning_content_on_assistant_messages
&& model.reasoning
&& assistant_msg.get("reasoning_content").is_none()
{
assistant_msg.insert("reasoning_content".into(), json!(""));
}
let has_content = match assistant_msg.get("content") {
Some(Value::Null) => false,
Some(Value::String(s)) => !s.is_empty(),
_ => false,
};
if !has_content && !assistant_msg.contains_key("tool_calls") {
return None;
}
Some(Value::Object(assistant_msg))
}
fn convert_summary_message(msg: &AgentMessage) -> Value {
let (prefix, suffix) = if msg.role == "compactionSummary" {
(COMPACTION_SUMMARY_PREFIX, COMPACTION_SUMMARY_SUFFIX)
} else {
(BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX)
};
let text = format!("{}{}{}", prefix, msg.text(), suffix);
json!({ "role": "user", "content": [{"type": "text", "text": sanitize_surrogates(&text)}] })
}
fn convert_tool_result_message(msg: &AgentMessage, model: &ModelConfig) -> ConvertedToolResult {
let supports_images = model.input.iter().any(|x| x == "image");
let mut text_result: Vec<String> = Vec::new();
let mut image_parts: Vec<String> = Vec::new();
let mut previous_was_placeholder = false;
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
text_result.push(text.clone());
previous_was_placeholder =
text == "(tool image omitted: model does not support images)";
}
ContentBlock::Image { data, mime_type } => {
if supports_images {
image_parts.push(format!("data:{};base64,{}", mime_type, data));
previous_was_placeholder = false;
} else if !previous_was_placeholder {
text_result
.push("(tool image omitted: model does not support images)".to_string());
previous_was_placeholder = true;
}
}
_ => {}
}
}
let text = text_result.join("\n");
let has_images = !image_parts.is_empty();
let has_text = !text.is_empty();
let tool_result_text = if has_text {
text
} else if has_images {
"(see attached image)".to_string()
} else {
"(no tool output)".to_string()
};
let mut tool_msg = serde_json::Map::new();
tool_msg.insert("role".into(), json!("tool"));
tool_msg.insert(
"content".into(),
json!(sanitize_surrogates(&tool_result_text)),
);
tool_msg.insert(
"tool_call_id".into(),
json!(
msg.tool_call_id
.clone()
.map(|id| normalize_tool_call_id(&id))
.unwrap_or_default()
),
);
ConvertedToolResult {
tool: Value::Object(tool_msg),
image_urls: image_parts,
}
}
fn finish_tool_batch(
params: &mut Vec<Value>,
deferred_image_urls: &mut Vec<String>,
model: &ModelConfig,
) {
if !deferred_image_urls.is_empty() {
let mut arr: Vec<Value> = vec![json!({
"type": "text",
"text": "Attached image(s) from tool result:"
})];
arr.extend(deferred_image_urls.drain(..).map(|url| {
json!({
"type": "image_url",
"image_url": { "url": url }
})
}));
params.push(json!({ "role": "user", "content": arr }));
}
if model.requires_assistant_after_tool_result {
params.push(json!({ "role": "assistant", "content": [] }));
}
}
fn close_tool_batch(
params: &mut Vec<Value>,
pending: &mut Vec<(String, String)>,
deferred_image_urls: &mut Vec<String>,
model: &ModelConfig,
had_results: bool,
) {
if pending.is_empty() {
return; }
insert_synthetic_tool_results(params, pending);
if !had_results {
return; }
finish_tool_batch(params, deferred_image_urls, model);
}
pub fn convert_messages(
messages: &[AgentMessage],
system_prompt: &str,
model: &ModelConfig,
) -> Vec<Value> {
let mut params: Vec<Value> = Vec::new();
let supports_mid_convo = supports_mid_convo_system_messages(model);
let mut pending_system: Vec<Value> = Vec::new();
if !system_prompt.is_empty() {
params.push(json!({
"role": instruction_role(model),
"content": sanitize_surrogates(system_prompt)
}));
}
let mut pending_tool_calls: Vec<(String, String)> = Vec::new(); let mut deferred_image_urls: Vec<String> = Vec::new();
let mut batch_emitted_results = false;
for msg in messages {
match msg.role.as_str() {
"user" => {
close_tool_batch(
&mut params,
&mut pending_tool_calls,
&mut deferred_image_urls,
model,
batch_emitted_results,
);
batch_emitted_results = false;
flush_pending_system_messages(&mut params, &mut pending_system);
if let Some(user_msg) = convert_user_message(msg, model) {
params.push(user_msg);
}
}
"assistant" => {
close_tool_batch(
&mut params,
&mut pending_tool_calls,
&mut deferred_image_urls,
model,
batch_emitted_results,
);
batch_emitted_results = false;
flush_pending_system_messages(&mut params, &mut pending_system);
if let Some(assistant_msg) =
convert_assistant_message(msg, model, &mut pending_tool_calls)
{
params.push(assistant_msg);
}
}
"compactionSummary" | "branchSummary" => {
close_tool_batch(
&mut params,
&mut pending_tool_calls,
&mut deferred_image_urls,
model,
batch_emitted_results,
);
batch_emitted_results = false;
flush_pending_system_messages(&mut params, &mut pending_system);
params.push(convert_summary_message(msg));
}
"custom" => {
close_tool_batch(
&mut params,
&mut pending_tool_calls,
&mut deferred_image_urls,
model,
batch_emitted_results,
);
batch_emitted_results = false;
flush_pending_system_messages(&mut params, &mut pending_system);
if let Some(user_msg) = convert_user_message(msg, model) {
params.push(user_msg);
}
}
"system" => {
if supports_mid_convo && let Some(system_msg) = convert_system_message(msg, model) {
pending_system.push(system_msg);
}
}
"toolResult" => {
let norm_id = msg.tool_call_id.as_deref().map(normalize_tool_call_id);
match norm_id {
Some(id) if pending_tool_calls.iter().any(|(p, _)| *p == id) => {
pending_tool_calls.retain(|(p, _)| *p != id);
let converted = convert_tool_result_message(msg, model);
params.push(converted.tool);
deferred_image_urls.extend(converted.image_urls);
batch_emitted_results = true;
if pending_tool_calls.is_empty() {
finish_tool_batch(&mut params, &mut deferred_image_urls, model);
batch_emitted_results = false;
}
}
_ => {}
}
}
_ => {}
}
}
close_tool_batch(
&mut params,
&mut pending_tool_calls,
&mut deferred_image_urls,
model,
batch_emitted_results,
);
flush_pending_system_messages(&mut params, &mut pending_system);
params
}
pub fn convert_tools(tool_defs: &[(String, String, Value)]) -> Vec<Value> {
convert_tools_inner(tool_defs, false, false)
}
pub fn convert_tools_with_strict(
tool_defs: &[(String, String, Value)],
strict: bool,
) -> Vec<Value> {
convert_tools_inner(tool_defs, strict, false)
}
pub(crate) fn convert_tools_with_capabilities(
tool_defs: &[(String, String, Value)],
strict: bool,
grammar: bool,
) -> Vec<Value> {
convert_tools_inner(tool_defs, strict, grammar)
}
fn tool_has_constrained_sampling(name: &str) -> bool {
if let Some(def) = tool_defs(std::slice::from_ref(&name.to_string())).first()
&& def.constrained_sampling
{
return true;
}
core::extensions::registered().iter().any(|ext| {
ext.tools()
.iter()
.any(|t| t.name == name && t.constrained_sampling)
})
}
fn convert_tools_inner(
tool_defs: &[(String, String, Value)],
strict_supported: bool,
grammar_supported: bool,
) -> Vec<Value> {
tool_defs
.iter()
.map(|(name, description, parameters)| {
if grammar_supported && let Some(grammar) = core::provider::grammar_tool(parameters) {
return json!({
"type": "custom",
"custom": {
"name": name,
"description": description,
"format": {
"type": "grammar",
"grammar": {
"syntax": grammar.syntax,
"definition": grammar.definition,
}
}
}
});
}
let mut params = parameters.clone();
let this_strict = strict_supported && tool_has_constrained_sampling(name);
if this_strict && params.is_object() {
if !params.get("additionalProperties").is_some() {
params["additionalProperties"] = json!(false);
}
if let Some(props) = params.get("properties").and_then(|p| p.as_object()) {
let required: Vec<Value> = props.keys().map(|k| json!(k)).collect();
if !params.get("required").is_some() {
params["required"] = Value::Array(required);
}
}
}
let mut function =
json!({ "name": name, "description": description, "parameters": params });
if strict_supported {
function["strict"] = json!(this_strict);
}
json!({ "type": "function", "function": function })
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn stream(
model: &ModelConfig,
messages: &[AgentMessage],
system_prompt: &str,
tools: &[(String, String, Value)],
reasoning_effort: Option<&str>,
temperature: Option<f64>,
max_tokens: Option<u32>,
mut on_event: Option<&mut PartialTranslator<'_>>,
on_payload: Option<crate::core::provider::PayloadHook>,
tool_choice: Option<&str>,
) -> Result<StreamResult> {
let level_sampling_params = model.sampling_params_for_level(reasoning_effort).cloned();
let reasoning_effort = normalize_reasoning_effort(model, reasoning_effort);
let client = http::build_client()?;
let effective_system_prompt =
collapse_mid_convo_system_messages(messages, system_prompt, model);
let mut openai_messages = convert_messages(messages, &effective_system_prompt, model);
openai_messages.retain(|m| !m.is_null());
let mut request = build_chat_request(model, &openai_messages, tools, temperature, max_tokens);
if let Some(tc) = tool_choice {
request.tool_choice = Some(tc.to_string());
}
apply_thinking_config(&mut request, model, reasoning_effort.as_deref());
let mut body = serialize_request_body(&request, model, level_sampling_params.as_ref())?;
apply_payload_hook(&mut body, on_payload.as_ref());
let response = send_completion_request(&client, model, body).await?;
let mut stream = response.bytes_stream();
let mut state = StreamState::new();
let mut buf: Vec<u8> = Vec::new();
let mut total_bytes: usize = 0;
let capture_stream_events = core::extensions::has_provider_stream_event_handlers();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => return Err(Error::msg(format!("stream read failed: {}", e))),
};
track_stream_bytes(&mut total_bytes, chunk.len())?;
let (lines, carry) = split_sse_lines(&mut buf, &chunk);
buf = carry;
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some(data) = line.strip_prefix("data:") else {
continue;
};
let data = data.trim();
if data == "[DONE]" {
break;
}
let chunk_val: Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => continue,
};
if capture_stream_events {
core::extensions::dispatch_provider_stream_event(
&model.provider,
&model.api,
&model.model_id,
&chunk_val,
);
}
state.handle_chunk(&chunk_val, model, &mut on_event)?;
}
}
state.finish(model, &mut on_event)
}
fn normalize_reasoning_effort(
model: &ModelConfig,
reasoning_effort: Option<&str>,
) -> Option<String> {
match reasoning_effort {
Some(level) => {
let clamped = model.clamp_thinking_level(level);
if clamped == "off" {
None
} else {
Some(clamped)
}
}
None => None,
}
}
fn build_chat_request(
model: &ModelConfig,
openai_messages: &[Value],
tools: &[(String, String, Value)],
temperature: Option<f64>,
max_tokens: Option<u32>,
) -> ChatRequest {
ChatRequest {
model: azure::request_model_name(model).into_owned(),
messages: openai_messages.to_vec(),
stream: true,
max_tokens: max_tokens.or(model.max_tokens),
stream_options: if model.supports_usage_in_streaming {
Some(json!({ "include_usage": true }))
} else {
None
},
tools: if tools.is_empty() {
None
} else {
Some(convert_tools_with_capabilities(
tools,
model.supports_strict_mode,
model_resolver::supports_openai_grammar_tools(&model.provider, &model.model_id),
))
},
thinking: None,
reasoning_effort: None,
enable_thinking: None,
reasoning: None,
chat_template_kwargs: None,
temperature,
tool_choice: None,
}
}
fn apply_thinking_config(
request: &mut ChatRequest,
model: &ModelConfig,
reasoning_effort: Option<&str>,
) {
if !model.reasoning {
return;
}
let mapped = |level: &str| -> String {
model
.thinking_level_map
.as_ref()
.and_then(|m| m.get(level))
.and_then(|v| v.as_deref())
.unwrap_or(level)
.to_string()
};
let off_value = || model.thinking_level_map.as_ref().and_then(|m| m.get("off"));
let can_disable = || off_value().is_none_or(|v| v.is_some());
match model.thinking_format.as_str() {
"zai" => {
request.thinking = Some(if reasoning_effort.is_some() {
json!({ "type": "enabled", "clear_thinking": false })
} else {
json!({ "type": "disabled" })
});
if let Some(level) = reasoning_effort
&& model.supports_reasoning_effort
{
request.reasoning_effort = Some(mapped(level));
}
}
"qwen" => {
request.enable_thinking = Some(reasoning_effort.is_some());
if let Some(level) = reasoning_effort
&& model.supports_reasoning_effort
{
request.reasoning_effort = Some(mapped(level));
}
}
"qwen-chat-template" => {
request.chat_template_kwargs = Some(json!({
"enable_thinking": reasoning_effort.is_some(),
"preserve_thinking": true,
}));
}
"chat-template" => {
}
"baseten" => {
if model.supports_reasoning_effort {
request.reasoning_effort = match reasoning_effort {
Some(level) => Some(mapped(level)),
None => off_value().and_then(|v| v.clone()),
};
}
}
"deepseek" => {
if reasoning_effort.is_some() {
request.thinking = Some(json!({ "type": "enabled" }));
} else if can_disable() {
request.thinking = Some(json!({ "type": "disabled" }));
}
if let Some(level) = reasoning_effort
&& model.supports_reasoning_effort
{
request.reasoning_effort = Some(mapped(level));
}
}
"openrouter" => {
if let Some(level) = reasoning_effort {
request.reasoning = Some(json!({ "effort": mapped(level) }));
} else if can_disable() {
let off = off_value()
.and_then(|v| v.clone())
.unwrap_or_else(|| "none".to_string());
request.reasoning = Some(json!({ "effort": off }));
}
}
"ant-ling" => {
if let Some(level) = reasoning_effort
&& let Some(Some(effort)) =
model.thinking_level_map.as_ref().and_then(|m| m.get(level))
{
request.reasoning = Some(json!({ "effort": effort.clone() }));
}
}
"together" => {
request.reasoning = Some(json!({ "enabled": reasoning_effort.is_some() }));
if let Some(level) = reasoning_effort
&& model.supports_reasoning_effort
{
request.reasoning_effort = Some(mapped(level));
}
}
"string-thinking" => {
if let Some(level) = reasoning_effort {
request.thinking = Some(json!(mapped(level)));
} else if can_disable() {
let off = off_value()
.and_then(|v| v.clone())
.unwrap_or_else(|| "none".to_string());
request.thinking = Some(json!(off));
}
}
_ => {
if !model.supports_reasoning_effort {
return;
}
if let Some(level) = reasoning_effort {
request.reasoning_effort = Some(mapped(level));
} else if let Some(Some(off)) = off_value() {
request.reasoning_effort = Some(off.clone());
}
}
}
}
fn serialize_request_body(
request: &ChatRequest,
model: &ModelConfig,
level_sampling_params: Option<&Value>,
) -> Result<String> {
let mut v = serde_json::to_value(request).map_err(|source| Error::Json {
context: "failed to serialize request".to_string(),
source,
})?;
if let Some(mt) = request.max_tokens {
v[&model.max_tokens_field] = json!(mt);
}
if let Some(sp) = &model.sampling_params
&& let Some(obj) = sp.as_object()
{
for (k, val) in obj {
if !v.as_object().is_some_and(|o| o.contains_key(k)) {
v[k] = val.clone();
}
}
}
if let Some(r) = &model.provider_routing
&& !v.get("provider").is_some()
{
v["provider"] = r.clone();
}
if model.supports_store && !v.get("store").is_some() {
v["store"] = json!(false);
}
if let Some(obj) = v.as_object_mut() {
core::provider::apply_level_sampling_params(obj, level_sampling_params);
}
serde_json::to_string(&v).map_err(|source| Error::Json {
context: "failed to serialize request".to_string(),
source,
})
}
async fn send_completion_request(
client: &reqwest::Client,
model: &ModelConfig,
body: String,
) -> Result<reqwest::Response> {
let req = client
.post(model_endpoint(model))
.header("Content-Type", "application/json")
.bearer_auth(&model.api_key)
.headers(provider_extra_headers(model));
let response = req.body(body).send().await.map_err(Error::from)?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(Error::ProviderStatus {
status,
body: truncate_for_error(&text),
});
}
Ok(response)
}
fn split_sse_lines(buf: &mut Vec<u8>, chunk: &[u8]) -> (Vec<String>, Vec<u8>) {
buf.extend_from_slice(chunk);
let mut lines: Vec<String> = Vec::new();
let mut start = 0usize;
for i in 0..buf.len() {
if buf[i] == b'\n' {
lines.push(String::from_utf8_lossy(&buf[start..i]).to_string());
start = i + 1;
}
}
let carry = buf[start..].to_vec();
(lines, carry)
}
struct StreamState {
assistant_text: String,
thinking_text: String,
stop_reason: Option<String>,
_raw_stop_reason: Option<String>,
error_message: Option<String>,
usage: Option<Usage>,
response_id: Option<String>,
response_model: Option<String>,
tool_calls: Vec<StreamingToolCall>, tool_calls_by_id: HashMap<String, usize>,
text_index: Option<usize>,
thinking_index: Option<usize>,
toolcall_indices: HashMap<usize, usize>,
next_content_index: usize,
}
impl StreamState {
fn new() -> Self {
Self {
assistant_text: String::new(),
thinking_text: String::new(),
stop_reason: None,
_raw_stop_reason: None,
error_message: None,
usage: None,
response_id: None,
response_model: None,
tool_calls: Vec::new(),
tool_calls_by_id: HashMap::new(),
text_index: None,
thinking_index: None,
toolcall_indices: HashMap::new(),
next_content_index: 0,
}
}
fn handle_chunk(
&mut self,
chunk_val: &Value,
model: &ModelConfig,
on_event: &mut Option<&mut PartialTranslator<'_>>,
) -> Result<()> {
if let Some(err) = chunk_val.get("error") {
let message = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error")
.to_string();
return Err(Error::ProviderMessage(message));
}
if let Some(id) = chunk_val.get("id").and_then(|v| v.as_str())
&& self.response_id.is_none()
{
self.response_id = Some(id.to_string());
}
if let Some(m) = chunk_val.get("model").and_then(|v| v.as_str())
&& self.response_model.is_none()
&& m != model.model_id
{
self.response_model = Some(m.to_string());
}
if let Some(u) = chunk_val.get("usage") {
self.usage = parse_usage(u);
}
let choice = chunk_val
.get("choices")
.and_then(|c| c.as_array())
.and_then(|a| a.first());
let Some(choice) = choice else { return Ok(()) };
if let Some(u) = choice.get("usage")
&& self.usage.is_none()
{
self.usage = parse_usage(u);
}
if let Some(fr) = choice.get("finish_reason").and_then(|v| v.as_str())
&& !fr.is_empty()
{
self._raw_stop_reason = Some(fr.to_string());
let mapped = map_stop_reason(fr);
self.stop_reason = Some(mapped.0.to_string());
if let Some(em) = mapped.1 {
self.error_message = Some(em.to_string());
}
}
let delta = choice.get("delta");
let Some(delta) = delta else { return Ok(()) };
self.handle_delta(delta, on_event);
Ok(())
}
fn handle_delta(&mut self, delta: &Value, on_event: &mut Option<&mut PartialTranslator<'_>>) {
self.handle_content_delta(delta, on_event);
self.handle_reasoning_delta(delta, on_event);
self.handle_tool_call_delta(delta, on_event);
}
fn handle_content_delta(
&mut self,
delta: &Value,
on_event: &mut Option<&mut PartialTranslator<'_>>,
) {
let Some(content) = delta.get("content").and_then(|v| v.as_str()) else {
return;
};
if content.is_empty() {
return;
}
if self.text_index.is_none() {
self.text_index = Some(self.next_content_index);
self.next_content_index += 1;
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::TextStart {
content_index: self.text_index.unwrap(),
});
}
}
self.assistant_text.push_str(content);
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::TextDelta {
content_index: self.text_index.unwrap(),
delta: content.to_string(),
});
}
}
fn handle_reasoning_delta(
&mut self,
delta: &Value,
on_event: &mut Option<&mut PartialTranslator<'_>>,
) {
let mut found_reasoning: Option<&str> = None;
for field in ["reasoning_content", "reasoning", "reasoning_text"] {
if let Some(v) = delta.get(field).and_then(|v| v.as_str())
&& !v.is_empty()
{
found_reasoning = Some(field);
break;
}
}
let Some(field) = found_reasoning else { return };
let d = delta.get(field).and_then(|v| v.as_str()).unwrap_or("");
if d.is_empty() {
return;
}
if self.thinking_index.is_none() {
self.thinking_index = Some(self.next_content_index);
self.next_content_index += 1;
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ThinkingStart {
content_index: self.thinking_index.unwrap(),
});
}
}
self.thinking_text.push_str(d);
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ThinkingDelta {
content_index: self.thinking_index.unwrap(),
delta: d.to_string(),
});
}
}
fn handle_tool_call_delta(
&mut self,
delta: &Value,
on_event: &mut Option<&mut PartialTranslator<'_>>,
) {
let Some(tool_calls_delta) = delta.get("tool_calls").and_then(|v| v.as_array()) else {
return;
};
for tc in tool_calls_delta {
let index = tc.get("index").and_then(|v| v.as_u64()).map(|i| i as usize);
let id = tc.get("id").and_then(|v| v.as_str()).map(|s| s.to_string());
let function = tc.get("function");
let custom = tc.get("custom");
let name = function
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str())
.or_else(|| custom.and_then(|c| c.get("name")).and_then(|v| v.as_str()))
.map(|s| s.to_string());
let args = function
.and_then(|f| f.get("arguments"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let custom_input = custom
.and_then(|c| c.get("input"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let slot: Option<usize> = if let Some(i) = index {
Some(i)
} else {
id.as_ref()
.and_then(|id| self.tool_calls_by_id.get(id))
.copied()
};
let position = match slot {
Some(i) => {
while self.tool_calls.len() <= i {
self.tool_calls.push(StreamingToolCall {
index: self.tool_calls.len(),
id: None,
name: None,
arguments: String::new(),
grammar_input: None,
grammar_source: String::new(),
grammar_started: false,
grammar_closed: false,
});
}
i
}
None => {
let pos = self.tool_calls.len();
self.tool_calls.push(StreamingToolCall {
index: pos,
id: None,
name: None,
arguments: String::new(),
grammar_input: None,
grammar_source: String::new(),
grammar_started: false,
grammar_closed: false,
});
pos
}
};
if let Some(id) = id
&& self.tool_calls[position].id.is_none()
{
self.tool_calls[position].id = Some(id.clone());
self.tool_calls_by_id.insert(id, position);
}
if let Some(name) = name
&& self.tool_calls[position].name.is_none()
{
if custom.is_some() && function.is_none() {
self.tool_calls[position].grammar_input = Some(
grammar_input_property_for_tool(&name)
.unwrap_or_else(|| "input".to_string()),
);
}
self.tool_calls[position].name = Some(name);
}
if let Some(args) = args {
if !self.toolcall_indices.contains_key(&position) {
let ci = self.next_content_index;
self.next_content_index += 1;
self.toolcall_indices.insert(position, ci);
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ToolCallStart { content_index: ci });
}
}
self.tool_calls[position].arguments.push_str(&args);
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ToolCallDelta {
content_index: self.toolcall_indices[&position],
delta: args,
});
}
}
if let Some(input) = custom_input {
if !self.toolcall_indices.contains_key(&position) {
let ci = self.next_content_index;
self.next_content_index += 1;
self.toolcall_indices.insert(position, ci);
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ToolCallStart { content_index: ci });
}
}
let property = self.tool_calls[position]
.grammar_input
.clone()
.unwrap_or_else(|| "input".to_string());
if !self.tool_calls[position].grammar_started {
self.tool_calls[position].grammar_input = Some(property.clone());
self.tool_calls[position].grammar_started = true;
self.tool_calls[position].arguments.push_str(&format!(
"{{\"{}\":\"",
core::provider::escape_json_text(&property)
));
}
let previous = std::mem::take(&mut self.tool_calls[position].grammar_source);
let delta_text = input
.strip_prefix(previous.as_str())
.unwrap_or(input.as_str())
.to_string();
if !delta_text.is_empty() {
let escaped = core::provider::escape_json_text(&delta_text);
self.tool_calls[position].arguments.push_str(&escaped);
self.tool_calls[position].grammar_source = input;
if let Some(f) = on_event.as_mut() {
f.push(RawStreamEvent::ToolCallDelta {
content_index: self.toolcall_indices[&position],
delta: escaped,
});
}
}
}
}
}
fn finish(
mut self,
model: &ModelConfig,
on_event: &mut Option<&mut PartialTranslator<'_>>,
) -> Result<StreamResult> {
for tc in self.tool_calls.iter_mut() {
if tc.grammar_input.is_some() && !tc.grammar_closed {
if tc.grammar_started {
tc.arguments.push_str("\"}");
} else {
tc.arguments = "{}".to_string();
}
tc.grammar_closed = true;
}
}
let mut content_blocks: Vec<ContentBlock> = Vec::new();
if !self.thinking_text.trim().is_empty() {
content_blocks.push(ContentBlock::Thinking {
thinking: self.thinking_text.clone(),
thinking_signature: Some("reasoning_content".to_string()),
redacted: None,
});
if let Some(f) = on_event.as_mut()
&& let Some(ci) = self.thinking_index
{
f.push(RawStreamEvent::ThinkingEnd {
content_index: ci,
content: self.thinking_text.clone(),
});
}
}
if !self.assistant_text.is_empty() {
content_blocks.push(ContentBlock::Text {
text: self.assistant_text.clone(),
text_signature: None,
});
if let Some(f) = on_event.as_mut()
&& let Some(ci) = self.text_index
{
f.push(RawStreamEvent::TextEnd {
content_index: ci,
content: self.assistant_text,
});
}
}
for tc in &self.tool_calls {
let id = tc
.id
.clone()
.unwrap_or_else(|| format!("call_{}", tc.index));
let name = tc.name.clone().unwrap_or_default();
let arguments: Value = parse_streaming_json(&tc.arguments);
content_blocks.push(ContentBlock::ToolCall {
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
thought_signature: None,
namespace: None,
});
if let Some(f) = on_event.as_mut()
&& let Some(ci) = self.toolcall_indices.get(&tc.index)
{
f.push(RawStreamEvent::ToolCallEnd {
content_index: *ci,
id,
name,
arguments,
});
}
}
let stop_reason = self.stop_reason.or_else(|| {
if model.supports_finish_reason {
None
} else if !self.tool_calls.is_empty() {
Some("toolUse".to_string())
} else {
Some("stop".to_string())
}
});
let mut usage = self.usage;
if let Some(u) = usage.as_mut() {
compute_cost(u, model.cost.as_ref());
}
let message = AgentMessage {
role: "assistant".to_string(),
thinking_level: None,
content: content_blocks,
tool_call_id: None,
tool_name: None,
is_error: false,
stop_reason,
error_message: self.error_message.clone(),
model: self.response_model.or(Some(model.model_id.clone())),
provider: Some(model.provider.clone()),
api: Some("openai-completions".to_string()),
usage: usage.clone(),
response_model: None,
response_id: None,
diagnostics: None,
deferred: None,
raw_stop_reason: None,
end_turn: None,
timestamp: now_ms(),
duration_ms: None,
entry_id: None,
details: None,
citations: None,
};
Ok(StreamResult {
message,
usage,
error_message: self.error_message,
})
}
}
#[derive(Debug, Clone)]
struct StreamingToolCall {
index: usize,
id: Option<String>,
name: Option<String>,
arguments: String,
grammar_input: Option<String>,
grammar_source: String,
grammar_started: bool,
grammar_closed: bool,
}
fn map_stop_reason(reason: &str) -> (&'static str, Option<String>) {
match reason {
"stop" | "end" => ("stop", None),
"length" => ("length", None),
"function_call" | "tool_calls" => ("toolUse", None),
"content_filter" => (
"error",
Some("Provider finish_reason: content_filter".to_string()),
),
"network_error" => (
"error",
Some("Provider finish_reason: network_error".to_string()),
),
other => ("error", Some(format!("Provider finish_reason: {}", other))),
}
}
fn parse_usage(u: &Value) -> Option<Usage> {
let prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let cache_read = u
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.or_else(|| u.get("prompt_cache_hit_tokens").and_then(|v| v.as_u64()))
.unwrap_or(0) as u32;
let cache_write = u
.get("prompt_tokens_details")
.and_then(|d| d.get("cache_write_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output = u
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let reasoning = u
.pointer("/completion_tokens_details/reasoning_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let input = prompt_tokens
.saturating_sub(cache_read)
.saturating_sub(cache_write);
let total_tokens = input + output + cache_read + cache_write;
Some(Usage {
input,
output,
cache_read,
cache_write,
cache_write_1h: None,
reasoning: (reasoning > 0).then_some(reasoning),
total_tokens,
cost: Cost::default(),
})
}
fn model_endpoint(model: &ModelConfig) -> String {
if model.provider != "github-copilot" {
return model.endpoint();
}
let base = model
.api_key
.split(';')
.find_map(|seg| seg.strip_prefix("proxy-ep="))
.map(|ep| {
let ep = ep.trim();
let host = if let Some(rest) = ep.strip_prefix("proxy.") {
format!("api.{}", rest)
} else {
ep.to_string()
};
format!("https://{}", host)
});
match base {
Some(b) => azure::with_api_version(
model,
format!("{}/chat/completions", b.trim_end_matches('/')),
),
None => model.endpoint(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn copilot_model(token: &str) -> ModelConfig {
ModelConfig {
model_type: Default::default(),
image_resize: crate::utils::image::ImageResizeLimits::default(),
allowed_fallback_models: Vec::new(),
provider: "github-copilot".into(),
model_id: "claude-fable-5".into(),
base_url: "https://api.individual.githubcopilot.com".into(),
api_key: token.into(),
api: "openai-completions".into(),
output: Vec::new(),
input: vec!["text".into(), "image".into()],
reasoning: false,
max_tokens: None,
temperature: None,
context_window: 200000,
thinking_format: String::new(),
supports_reasoning_effort: false,
thinking_level_map: None,
auth_header: false,
supports_developer_role: false,
requires_reasoning_content_on_assistant_messages: false,
supports_usage_in_streaming: true,
supports_store: true,
supports_finish_reason: true,
requires_assistant_after_tool_result: false,
supports_strict_mode: false,
send_session_affinity_headers: None,
session_affinity_format: None,
max_tokens_field: "max_tokens".into(),
cost: None,
session_id: None,
max_retry_delay_ms: None,
sampling_params: None,
sampling_params_by_thinking_level: None,
provider_routing: None,
thinking_budgets: None,
}
}
#[test]
fn convert_messages_custom_role_becomes_user() {
let m = model(false, None);
let mut custom = AgentMessage::user_text("injected");
custom.role = "custom".to_string();
let msgs = convert_messages(&[custom], "", &m);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[0]["content"][0]["text"], "injected");
}
#[test]
fn trailing_orphan_tool_call_gets_synthetic_result() {
let m = model(false, None);
let mut a = AgentMessage::user_text("");
a.role = "assistant".into();
a.content = vec![ContentBlock::ToolCall {
id: "call_1".into(),
name: "read".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
}];
let out = convert_messages(&[a], "", &m);
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["tool_call_id"], "call_1");
assert_eq!(out[1]["content"], "No result provided");
}
#[test]
fn trailing_partial_tool_batch_backfills_missing_results() {
let m = model(false, None);
let mut a = AgentMessage::user_text("");
a.role = "assistant".into();
a.content = vec![
ContentBlock::ToolCall {
id: "call_A".into(),
name: "read".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
},
ContentBlock::ToolCall {
id: "call_B".into(),
name: "bash".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
},
];
let mut tr = AgentMessage::user_text("");
tr.role = "toolResult".into();
tr.tool_call_id = Some("call_A".into());
tr.content = vec![ContentBlock::Text {
text: "result A".into(),
text_signature: None,
}];
let out = convert_messages(&[a, tr], "", &m);
assert_eq!(out.len(), 3);
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["tool_call_id"], "call_A");
assert_eq!(out[1]["content"], "result A");
assert_eq!(out[2]["role"], "tool");
assert_eq!(out[2]["tool_call_id"], "call_B");
assert_eq!(out[2]["content"], "No result provided");
}
#[test]
fn orphan_tool_result_is_dropped() {
let m = model(false, None);
let mut user = AgentMessage::user_text("go");
user.role = "user".into();
let mut orphan = AgentMessage::user_text("");
orphan.role = "toolResult".into();
orphan.tool_call_id = Some("call_missing".into());
orphan.content = vec![ContentBlock::Text {
text: "orphan result".into(),
text_signature: None,
}];
let out = convert_messages(&[user, orphan], "", &m);
assert_eq!(out.len(), 1, "孤儿 tool 结果必须被丢弃: {out:?}");
assert_eq!(out[0]["role"], "user");
}
#[test]
fn answered_tool_result_still_emitted_once() {
let m = model(false, None);
let mut a = AgentMessage::user_text("");
a.role = "assistant".into();
a.content = vec![ContentBlock::ToolCall {
id: "call_1".into(),
name: "read".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
}];
let mk = |text: &str| {
let mut tr = AgentMessage::user_text("");
tr.role = "toolResult".into();
tr.tool_call_id = Some("call_1".into());
tr.content = vec![ContentBlock::Text {
text: text.into(),
text_signature: None,
}];
tr
};
let out = convert_messages(&[a, mk("first"), mk("second")], "", &m);
assert_eq!(out.len(), 2);
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["content"], "first");
}
fn assistant_with_calls(ids: &[&str]) -> AgentMessage {
let mut a = AgentMessage::user_text("");
a.role = "assistant".into();
a.content = ids
.iter()
.map(|id| ContentBlock::ToolCall {
id: (*id).into(),
name: "read".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
})
.collect();
a
}
fn tool_result_with_image(id: &str, text: &str, with_image: bool) -> AgentMessage {
let mut tr = AgentMessage::user_text("");
tr.role = "toolResult".into();
tr.tool_call_id = Some(id.into());
tr.content = vec![ContentBlock::Text {
text: text.into(),
text_signature: None,
}];
if with_image {
tr.content.push(ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
});
}
tr
}
#[test]
fn tool_result_images_wait_until_batch_end() {
let m = model(true, None);
let out = convert_messages(
&[
assistant_with_calls(&["call_read", "call_bash"]),
tool_result_with_image("call_read", "Read image file [image/png]", true),
tool_result_with_image("call_bash", "total 0", false),
AgentMessage::user_text("继续"),
],
"",
&m,
);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(
roles,
vec!["assistant", "tool", "tool", "user", "user"],
"图片消息不得插在两个 tool 消息之间: {out:?}"
);
assert_eq!(out[1]["tool_call_id"], "call_read");
assert_eq!(out[2]["tool_call_id"], "call_bash");
assert_eq!(
out[3]["content"][0]["text"],
"Attached image(s) from tool result:"
);
assert_eq!(out[3]["content"][1]["type"], "image_url");
assert_eq!(out[4]["content"][0]["text"], "继续");
}
#[test]
fn tool_result_image_at_list_end_follows_all_tool_messages() {
let m = model(true, None);
let out = convert_messages(
&[
assistant_with_calls(&["call_read", "call_bash"]),
tool_result_with_image("call_read", "Read image file [image/png]", true),
tool_result_with_image("call_bash", "total 0", false),
],
"",
&m,
);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(roles, vec!["assistant", "tool", "tool", "user"], "{out:?}");
}
#[test]
fn single_tool_result_image_follows_tool_message() {
let m = model(true, None);
let out = convert_messages(
&[
assistant_with_calls(&["call_read"]),
tool_result_with_image("call_read", "Read image file [image/png]", true),
],
"",
&m,
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["content"], "Read image file [image/png]");
assert_eq!(out[2]["role"], "user");
assert_eq!(out[2]["content"][1]["type"], "image_url");
assert_eq!(
out[2]["content"][1]["image_url"]["url"],
"data:image/png;base64,AA=="
);
}
#[test]
fn multiple_tool_result_images_merge_into_one_user_message() {
let m = model(true, None);
let out = convert_messages(
&[
assistant_with_calls(&["call_a", "call_b"]),
tool_result_with_image("call_a", "a", true),
tool_result_with_image("call_b", "b", true),
],
"",
&m,
);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(roles, vec!["assistant", "tool", "tool", "user"], "{out:?}");
let parts = out[3]["content"].as_array().unwrap();
assert_eq!(parts.len(), 3, "标题 + 两张图: {out:?}");
}
#[test]
fn empty_assistant_appended_once_per_tool_batch() {
let mut m = model(true, None);
m.requires_assistant_after_tool_result = true;
let out = convert_messages(
&[
assistant_with_calls(&["call_read", "call_bash"]),
tool_result_with_image("call_read", "Read image file [image/png]", true),
tool_result_with_image("call_bash", "total 0", false),
],
"",
&m,
);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(
roles,
vec!["assistant", "tool", "tool", "user", "assistant"],
"{out:?}"
);
assert!(out[4]["content"].as_array().unwrap().is_empty());
}
#[test]
fn tool_choice_serialized_when_set() {
let m = model(false, None);
let mut request = build_chat_request(&m, &[], &[], None, None);
let body = serialize_request_body(&request, &m, None).unwrap();
let v: Value = serde_json::from_str(&body).unwrap();
assert!(
v.get("tool_choice").is_none(),
"未设置时不含 tool_choice: {body}"
);
request.tool_choice = Some("none".to_string());
let body2 = serialize_request_body(&request, &m, None).unwrap();
let v2: Value = serde_json::from_str(&body2).unwrap();
assert_eq!(v2["tool_choice"], "none");
}
#[test]
fn model_endpoint_forwards_proxy_ep() {
let m = copilot_model("tid=1;exp=9;proxy-ep=proxy.individual.githubcopilot.com;sku=1");
assert_eq!(
model_endpoint(&m),
"https://api.individual.githubcopilot.com/chat/completions"
);
let m2 = copilot_model("tid=1");
assert_eq!(
model_endpoint(&m2),
"https://api.individual.githubcopilot.com/chat/completions"
);
let m3 = model(false, None);
assert_eq!(
model_endpoint(&m3),
"https://example.com/v1/chat/completions"
);
}
#[test]
fn provider_extra_headers_follows_provider() {
let m = copilot_model("x");
let h = crate::core::provider::provider_extra_headers(&m);
assert_eq!(
h.get("User-Agent").and_then(|v| v.to_str().ok()),
Some("GitHubCopilotChat/0.35.0")
);
assert!(h.get("Copilot-Integration-Id").is_some());
let m2 = model(false, None);
let h2 = crate::core::provider::provider_extra_headers(&m2);
assert!(h2.is_empty());
}
fn model(supports_image: bool, cost: Option<Value>) -> ModelConfig {
ModelConfig {
model_type: Default::default(),
image_resize: crate::utils::image::ImageResizeLimits::default(),
allowed_fallback_models: Vec::new(),
provider: "test".into(),
model_id: "m".into(),
base_url: "https://example.com/v1".into(),
api_key: String::new(),
api: "openai-completions".into(),
output: Vec::new(),
input: if supports_image {
vec!["text".into(), "image".into()]
} else {
vec!["text".into()]
},
reasoning: false,
max_tokens: None,
temperature: None,
context_window: 1000,
thinking_format: String::new(),
supports_reasoning_effort: false,
thinking_level_map: None,
auth_header: false,
supports_developer_role: false,
requires_reasoning_content_on_assistant_messages: false,
supports_usage_in_streaming: true,
supports_store: true,
supports_finish_reason: true,
requires_assistant_after_tool_result: false,
max_tokens_field: "max_tokens".into(),
supports_strict_mode: false,
send_session_affinity_headers: None,
session_affinity_format: None,
cost,
session_id: None,
max_retry_delay_ms: None,
sampling_params: None,
sampling_params_by_thinking_level: None,
provider_routing: None,
thinking_budgets: None,
}
}
#[test]
fn non_vision_model_replaces_images() {
let m = model(false, None);
let msg = AgentMessage {
role: "user".into(),
thinking_level: None,
content: vec![
ContentBlock::Text {
text: "look".into(),
text_signature: None,
},
ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
},
ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
},
],
..AgentMessage::user_text("")
};
let out = convert_messages(&[msg], "", &m);
let content = out[0].get("content").unwrap();
let parts = content.as_array().unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["text"], "look");
assert_eq!(
parts[1]["text"],
"(image omitted: model does not support images)"
);
}
#[test]
fn image_only_user_message_omits_empty_text_part() {
let m = model(true, None);
let msg = AgentMessage {
role: "user".into(),
thinking_level: None,
content: vec![
ContentBlock::Text {
text: String::new(),
text_signature: None,
},
ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
},
],
..AgentMessage::user_text("")
};
let out = convert_messages(&[msg], "", &m);
let parts = out[0]["content"].as_array().unwrap();
assert_eq!(parts.len(), 1, "空 text part 必须被省略: {out:?}");
assert_eq!(parts[0]["type"], "image_url");
}
#[test]
fn image_block_serializes_pi_compatible() {
let msg = AgentMessage {
role: "user".into(),
thinking_level: None,
content: vec![ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
}],
..AgentMessage::user_text("")
};
let v = serde_json::to_value(&msg).unwrap();
assert_eq!(v["content"][0]["mimeType"], "image/png");
}
#[test]
fn summary_roles_become_user_context() {
let m = model(false, None);
let mut c = AgentMessage::user_text("sum");
c.role = "compactionSummary".into();
let out = convert_messages(&[c], "", &m);
let text = out[0]["content"][0]["text"].as_str().unwrap();
assert!(text.starts_with("The conversation history before this point"));
assert!(text.contains("sum"));
}
#[test]
fn vision_model_emits_image_urls() {
let m = model(true, None);
let msg = AgentMessage {
role: "user".into(),
thinking_level: None,
content: vec![ContentBlock::Image {
data: "AA==".into(),
mime_type: "image/png".into(),
}],
..AgentMessage::user_text("")
};
let out = convert_messages(&[msg], "", &m);
let part = &out[0]["content"][0];
assert_eq!(part["type"], "image_url");
assert_eq!(part["image_url"]["url"], "data:image/png;base64,AA==");
}
#[test]
fn serialize_merges_sampling_params_and_routing() {
let mut m = copilot_model("x");
m.sampling_params = Some(json!({ "top_p": 0.9, "min_p": 0.05 }));
m.provider_routing = Some(json!({ "order": ["openai"], "allow_fallbacks": false }));
let req = ChatRequest {
model: m.model_id.clone(),
messages: vec![json!({ "role": "user", "content": "hi" })],
stream: true,
max_tokens: None,
stream_options: None,
tools: None,
thinking: None,
reasoning_effort: None,
enable_thinking: None,
reasoning: None,
chat_template_kwargs: None,
temperature: Some(0.7),
tool_choice: None,
};
let body = serialize_request_body(&req, &m, None).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["top_p"], 0.9);
assert_eq!(v["min_p"], 0.05);
assert_eq!(v["temperature"], 0.7);
assert_eq!(v["provider"]["order"][0], "openai");
assert_eq!(v["provider"]["allow_fallbacks"], false);
}
#[test]
fn level_sampling_params_override_named_fields() {
let mut m = copilot_model("x");
m.sampling_params = Some(json!({ "temperature": 1.0, "top_p": 0.95 }));
let req = ChatRequest {
model: m.model_id.clone(),
messages: vec![json!({ "role": "user", "content": "hi" })],
stream: true,
max_tokens: None,
stream_options: None,
tools: None,
thinking: None,
reasoning_effort: None,
enable_thinking: None,
reasoning: None,
chat_template_kwargs: None,
temperature: Some(1.0),
tool_choice: None,
};
let level = json!({ "temperature": 0.6, "top_k": 64 });
let body = serialize_request_body(&req, &m, Some(&level)).unwrap();
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["temperature"], 0.6, "级别覆盖应压过显式 temperature");
assert_eq!(v["top_k"], 64);
assert_eq!(v["top_p"], 0.95, "目录 samplingParams 仍按补空键合并");
let body = serialize_request_body(&req, &m, None).unwrap();
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["temperature"], 1.0);
assert!(v.get("top_k").is_none());
}
#[test]
fn usage_in_streaming_omits_include_usage_when_unsupported() {
let mut m = copilot_model("x");
m.supports_usage_in_streaming = false;
let req = build_chat_request(&m, &[], &[], None, Some(8));
assert!(
req.stream_options.is_none(),
"supportsUsageInStreaming=false 不发 include_usage"
);
let m2 = copilot_model("x");
let req2 = build_chat_request(&m2, &[], &[], None, Some(8));
assert!(
req2.stream_options.is_some(),
"supportsUsageInStreaming=true 默认发 include_usage"
);
}
#[test]
fn thinking_format_variants_serialize() {
let with_map = |mut m: ModelConfig, pairs: &[(&str, Option<&str>)]| {
m.thinking_level_map = Some(
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.map(|s| s.to_string())))
.collect(),
);
m
};
let mut m = copilot_model("x");
m.reasoning = true;
m.thinking_format = "qwen".into();
m.supports_reasoning_effort = true;
let mut req = build_chat_request(&m, &[], &[], None, None);
apply_thinking_config(&mut req, &m, Some("high"));
assert_eq!(req.enable_thinking, Some(true));
assert_eq!(req.reasoning_effort.as_deref(), Some("high"));
let body = serialize_request_body(&req, &m, None).unwrap();
assert!(body.contains("\"enable_thinking\":true"), "{body}");
let mut m2 = copilot_model("x");
m2.reasoning = true;
m2.thinking_format = "together".into();
let mut req2 = build_chat_request(&m2, &[], &[], None, None);
apply_thinking_config(&mut req2, &m2, Some("medium"));
assert_eq!(req2.reasoning, Some(json!({ "enabled": true })));
let mut req2_off = build_chat_request(&m2, &[], &[], None, None);
apply_thinking_config(&mut req2_off, &m2, None);
assert_eq!(req2_off.reasoning, Some(json!({ "enabled": false })));
let mut m3 = copilot_model("x");
m3.reasoning = true;
m3.thinking_format = "openrouter".into();
let mut req3 = build_chat_request(&m3, &[], &[], None, None);
apply_thinking_config(&mut req3, &m3, None);
assert_eq!(req3.reasoning, Some(json!({ "effort": "none" })));
let mut m4 = copilot_model("x");
m4.reasoning = true;
m4.thinking_format = "deepseek".into();
let mut req4 = build_chat_request(&m4, &[], &[], None, None);
apply_thinking_config(&mut req4, &m4, None);
assert_eq!(req4.thinking, Some(json!({ "type": "disabled" })));
let m5 = with_map(
{
let mut m = copilot_model("x");
m.reasoning = true;
m.thinking_format = "deepseek".into();
m
},
&[("off", None), ("high", Some("high"))],
);
let mut req5 = build_chat_request(&m5, &[], &[], None, None);
apply_thinking_config(&mut req5, &m5, None);
assert_eq!(req5.thinking, None);
assert_eq!(req5.reasoning_effort, None);
let mut m6 = copilot_model("x");
m6.reasoning = true;
m6.thinking_format = "zai".into();
let mut req6 = build_chat_request(&m6, &[], &[], None, None);
apply_thinking_config(&mut req6, &m6, None);
assert_eq!(req6.thinking, Some(json!({ "type": "disabled" })));
let m7 = with_map(
{
let mut m = copilot_model("x");
m.reasoning = true;
m.thinking_format = "openai".into();
m.supports_reasoning_effort = true;
m
},
&[("off", Some("none")), ("high", Some("high"))],
);
let mut req7 = build_chat_request(&m7, &[], &[], None, None);
apply_thinking_config(&mut req7, &m7, None);
assert_eq!(req7.reasoning_effort.as_deref(), Some("none"));
let mut req7_on = build_chat_request(&m7, &[], &[], None, None);
apply_thinking_config(&mut req7_on, &m7, Some("high"));
assert_eq!(req7_on.reasoning_effort.as_deref(), Some("high"));
let mut m8 = copilot_model("x");
m8.reasoning = true;
m8.thinking_format = "openai".into();
m8.supports_reasoning_effort = false;
let mut req8 = build_chat_request(&m8, &[], &[], None, None);
apply_thinking_config(&mut req8, &m8, Some("high"));
assert_eq!(req8.reasoning_effort, None);
}
#[test]
fn strict_mode_follows_tool_constrained_sampling() {
let defs = vec![
(
"read".to_string(),
"Read".to_string(),
json!({ "type": "object", "properties": { "path": { "type": "string" } } }),
),
(
"ls".to_string(),
"Ls".to_string(),
json!({ "type": "object", "properties": { "path": { "type": "string" } } }),
),
];
let plain = convert_tools_with_strict(&defs, false);
assert!(plain[0]["function"].get("strict").is_none());
assert!(plain[0]["function"]["parameters"].get("required").is_none());
let strict = convert_tools_with_strict(&defs, true);
assert_eq!(strict[0]["function"]["strict"], true);
assert_eq!(
strict[0]["function"]["parameters"]["additionalProperties"],
false
);
assert_eq!(strict[0]["function"]["parameters"]["required"][0], "path");
assert_eq!(strict[1]["function"]["strict"], false);
assert!(
strict[1]["function"]["parameters"]
.get("required")
.is_none()
);
}
#[test]
fn finish_message_usage_cost_set_from_model_pricing_before_clone() {
let m = model(
false,
Some(json!({
"input": 1.0,
"output": 2.0,
"cacheRead": 0.5,
"cacheWrite": 0.25,
})),
);
let mut st = StreamState::new();
st.usage = Some(Usage {
input: 1000,
output: 2000,
cache_read: 0,
cache_write: 0,
cache_write_1h: None,
reasoning: None,
total_tokens: 3000,
cost: Cost::default(),
});
let res = st.finish(&m, &mut None).unwrap();
let u = res.message.usage.expect("usage attached to message");
assert!(
(u.cost.total - 0.005).abs() < 1e-12,
"message cost.total={} expected 0.005",
u.cost.total
);
assert!((res.usage.unwrap().cost.total - 0.005).abs() < 1e-12);
}
#[test]
fn grammar_tool_conversion_emits_custom_tool() {
let tools = vec![(
"codemode".to_string(),
"run js".to_string(),
json!({
"type": "object",
"properties": { "code": { "type": "string" } },
"required": ["code"],
"x-prux-grammar": { "openai_lark": "start: SOURCE", "input": "code" }
}),
)];
let with_grammar = convert_tools_with_capabilities(&tools, false, true);
assert_eq!(with_grammar[0]["type"], "custom");
assert_eq!(with_grammar[0]["custom"]["name"], "codemode");
assert_eq!(with_grammar[0]["custom"]["format"]["type"], "grammar");
assert_eq!(
with_grammar[0]["custom"]["format"]["grammar"]["syntax"],
"lark"
);
assert_eq!(
with_grammar[0]["custom"]["format"]["grammar"]["definition"],
"start: SOURCE"
);
assert!(with_grammar[0]["custom"].get("parameters").is_none());
let without = convert_tools_with_capabilities(&tools, false, false);
assert_eq!(without[0]["type"], "function");
assert_eq!(without[0]["function"]["name"], "codemode");
}
#[test]
fn grammar_custom_input_is_wrapped_into_json_arguments() {
let m = model(false, None);
let mut st = StreamState::new();
let mut on_event = None;
st.handle_tool_call_delta(
&json!({"tool_calls": [{
"index": 0,
"id": "call_1",
"type": "custom",
"custom": { "name": "codemode", "input": "" }
}]}),
&mut on_event,
);
st.handle_tool_call_delta(
&json!({"tool_calls": [{
"index": 0,
"type": "custom",
"custom": { "name": "codemode", "input": "return \"a\";" }
}]}),
&mut on_event,
);
st.handle_tool_call_delta(
&json!({"tool_calls": [{
"index": 0,
"type": "custom",
"custom": { "name": "codemode", "input": "return \"a\";\n// 换行" }
}]}),
&mut on_event,
);
let res = st.finish(&m, &mut None).unwrap();
let arguments = res
.message
.content
.iter()
.find_map(|b| match b {
ContentBlock::ToolCall { arguments, .. } => Some(arguments.clone()),
_ => None,
})
.expect("tool call block");
assert_eq!(arguments, json!({ "input": "return \"a\";\n// 换行" }));
}
fn register_codemode_grammar_probe() {
struct GrammarProbe;
impl crate::core::extensions::Extension for GrammarProbe {
fn name(&self) -> &str {
"zz-codemode-replay-probe"
}
fn modes(&self) -> Vec<crate::core::extensions::ExtensionMode> {
crate::core::extensions::ExtensionMode::ALL.to_vec()
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
let mut tool = crate::core::extensions::ExtensionTool::simple(
"codemode",
"run js",
json!({
"type": "object",
"properties": { "code": { "type": "string" } },
"required": ["code"]
}),
"run js",
);
tool.grammar_sampling = Some(crate::core::extensions::GrammarSampling {
openai_lark: Some("start: SOURCE".to_string()),
openai_regex: None,
});
vec![tool]
}
}
crate::core::extensions::register_extension(GrammarProbe);
assert_eq!(
grammar_input_property_for_tool("codemode").as_deref(),
Some("code"),
"探针扩展未生效(模式/禁用状态异常)"
);
}
#[test]
fn grammar_tool_replay_follows_model_capability() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
register_codemode_grammar_probe();
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".into();
msg.stop_reason = Some("toolUse".into());
msg.content = vec![ContentBlock::ToolCall {
id: "call_1".into(),
name: "codemode".into(),
arguments: json!({ "code": "text(1)" }),
thought_signature: None,
namespace: None,
}];
let plain = convert_messages(std::slice::from_ref(&msg), "", &model(false, None));
let call = &plain[0]["tool_calls"][0];
assert_eq!(call["type"], "function");
assert_eq!(call["function"]["name"], "codemode");
assert_eq!(call["function"]["arguments"], "{\"code\":\"text(1)\"}");
let mut grammar_model = model(false, None);
grammar_model.provider = "opencode".into();
grammar_model.model_id = "gpt-5".into();
assert!(model_resolver::supports_openai_grammar_tools(
&grammar_model.provider,
&grammar_model.model_id
));
let custom = convert_messages(&[msg], "", &grammar_model);
let call = &custom[0]["tool_calls"][0];
assert_eq!(call["type"], "custom");
assert_eq!(call["custom"]["input"], "text(1)");
crate::core::extensions::unregister_extension("zz-codemode-replay-probe");
}
fn mid_convo_model() -> ModelConfig {
let mut m = model(false, None);
m.provider = "moonshotai".into();
m.model_id = "kimi-k2.6".into();
m.api = "openai-completions".into();
assert!(supports_mid_convo_system_messages(&m));
m
}
fn tool_call_assistant(id: &str) -> AgentMessage {
let mut a = AgentMessage::user_text("");
a.role = "assistant".into();
a.content = vec![ContentBlock::ToolCall {
id: id.into(),
name: "read".into(),
arguments: json!({}),
thought_signature: None,
namespace: None,
}];
a
}
#[test]
fn native_mid_convo_system_messages_are_held_until_the_next_non_tool_message() {
let m = mid_convo_model();
let mut note = AgentMessage::user_text("mid-run note");
note.role = "system".into();
let mut tr = AgentMessage::user_text("contents");
tr.role = "toolResult".into();
tr.tool_call_id = Some("call_1".into());
let mut a2 = AgentMessage::user_text("");
a2.role = "assistant".into();
a2.content = vec![ContentBlock::Text {
text: "done".into(),
text_signature: None,
}];
let out = convert_messages(&[tool_call_assistant("call_1"), note, tr, a2], "", &m);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(roles, vec!["assistant", "tool", "system", "assistant"]);
assert_eq!(out[2]["content"], "mid-run note");
}
#[test]
fn system_before_a_missing_tool_result_lands_after_the_synthetic_result() {
let m = mid_convo_model();
let mut note = AgentMessage::user_text("late note");
note.role = "system".into();
let out = convert_messages(&[tool_call_assistant("call_1"), note], "", &m);
assert_eq!(out.len(), 3);
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["content"], "No result provided");
assert_eq!(out[2]["role"], "system");
assert_eq!(out[2]["content"], "late note");
}
#[test]
fn held_system_message_is_flushed_before_the_user_turn() {
let m = mid_convo_model();
let mut note = AgentMessage::user_text("note");
note.role = "system".into();
let mut u = AgentMessage::user_text("next");
u.role = "user".into();
let out = convert_messages(&[tool_call_assistant("call_1"), note, u], "", &m);
let roles: Vec<&str> = out.iter().map(|v| v["role"].as_str().unwrap()).collect();
assert_eq!(roles, vec!["assistant", "tool", "system", "user"]);
}
#[test]
fn inline_system_messages_are_dropped_without_the_compat_flag() {
let m = model(false, None);
let mut note = AgentMessage::user_text("note");
note.role = "system".into();
let out = convert_messages(&[AgentMessage::user_text("hi"), note], "", &m);
assert!(!out.iter().any(|v| v["role"] == "system"), "{out:?}");
}
#[test]
fn collapse_folds_mid_convo_system_messages_for_unsupported_models() {
let mut note = AgentMessage::user_text("note");
note.role = "system".into();
let messages = vec![AgentMessage::user_text("hi"), note];
assert_eq!(
collapse_mid_convo_system_messages(&messages, "base", &model(false, None)),
"base\n\nnote"
);
assert_eq!(
collapse_mid_convo_system_messages(&messages, "base", &mid_convo_model()),
"base"
);
}
}