Compare commits
6 Commits
rust
...
441270317c
| Author | SHA1 | Date | |
|---|---|---|---|
| 441270317c | |||
| 2e4318d84b | |||
| d0be16d8e3 | |||
| 83e0ad0240 | |||
| 275ce34d05 | |||
| cb5b921550 |
@@ -36,6 +36,79 @@ impl OpenAIProvider {
|
||||
pricing: app_config.pricing.openai.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GPT-5.4 models sometimes emit parallel tool calls as a JSON block starting with
|
||||
/// '{"tool_uses":' inside a text message instead of discrete function_call items.
|
||||
/// This method attempts to extract and parse such tool calls.
|
||||
pub fn parse_tool_uses_json(text: &str) -> Vec<crate::models::ToolCall> {
|
||||
let mut calls = Vec::new();
|
||||
if let Some(start) = text.find("{\"tool_uses\":") {
|
||||
// ... (rest of method unchanged)
|
||||
// Find the end of the JSON block by matching braces
|
||||
let sub = &text[start..];
|
||||
let mut brace_count = 0;
|
||||
let mut end_idx = 0;
|
||||
let mut found = false;
|
||||
|
||||
for (i, c) in sub.char_indices() {
|
||||
if c == '{' { brace_count += 1; }
|
||||
else if c == '}' {
|
||||
brace_count -= 1;
|
||||
if brace_count == 0 {
|
||||
end_idx = i + 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
let json_str = &sub[..end_idx];
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if let Some(uses) = val.get("tool_uses").and_then(|u| u.as_array()) {
|
||||
for (idx, u) in uses.iter().enumerate() {
|
||||
let name = u.get("recipient_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
// Strip "functions." prefix if present
|
||||
.replace("functions.", "");
|
||||
let arguments = u.get("parameters")
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
calls.push(crate::models::ToolCall {
|
||||
id: format!("call_tu_{}_{}", uuid::Uuid::new_v4().to_string()[..8].to_string(), idx),
|
||||
call_type: "function".to_string(),
|
||||
function: crate::models::FunctionCall { name, arguments },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
/// Strips internal metadata prefixes like 'to=multi_tool_use.parallel' from model responses.
|
||||
pub fn strip_internal_metadata(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Patterns to strip
|
||||
let patterns = [
|
||||
"to=multi_tool_use.parallel",
|
||||
"to=functions.multi_tool_use",
|
||||
"ส่งเงินบาทไทยjson", // User reported Thai text preamble
|
||||
];
|
||||
|
||||
for p in patterns {
|
||||
if let Some(start) = result.find(p) {
|
||||
// Remove the pattern and any whitespace around it
|
||||
result.replace_range(start..start + p.len(), "");
|
||||
}
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -112,10 +185,57 @@ impl super::Provider for OpenAIProvider {
|
||||
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
||||
let mut input_parts = Vec::new();
|
||||
for m in &messages_json {
|
||||
let mut role = m["role"].as_str().unwrap_or("user").to_string();
|
||||
// Newer models (gpt-5, o1) prefer "developer" over "system"
|
||||
if role == "system" {
|
||||
role = "developer".to_string();
|
||||
let role = m["role"].as_str().unwrap_or("user");
|
||||
|
||||
if role == "tool" {
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"output": m.get("content").and_then(|v| v.as_str()).unwrap_or("")
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if role == "assistant" && m.get("tool_calls").is_some() {
|
||||
// Push message part if it exists
|
||||
let content_val = m.get("content").cloned().unwrap_or(serde_json::json!(""));
|
||||
if !content_val.is_null() && (content_val.is_array() && !content_val.as_array().unwrap().is_empty() || content_val.is_string() && !content_val.as_str().unwrap().is_empty()) {
|
||||
let mut content = content_val.clone();
|
||||
if let Some(text) = content.as_str() {
|
||||
content = serde_json::json!([{ "type": "output_text", "text": text }]);
|
||||
} else if let Some(arr) = content.as_array_mut() {
|
||||
for part in arr {
|
||||
if let Some(obj) = part.as_object_mut() {
|
||||
if obj.get("type").and_then(|v| v.as_str()) == Some("text") {
|
||||
obj.insert("type".to_string(), serde_json::json!("output_text"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
|
||||
// Push tool calls as separate items
|
||||
if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for tc in tcs {
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "function_call",
|
||||
"call_id": tc["id"],
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"]
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mapped_role = role.to_string();
|
||||
if mapped_role == "system" {
|
||||
mapped_role = "developer".to_string();
|
||||
}
|
||||
|
||||
let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([]));
|
||||
@@ -127,12 +247,11 @@ impl super::Provider for OpenAIProvider {
|
||||
if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
|
||||
match t {
|
||||
"text" => {
|
||||
let new_type = if role == "assistant" { "output_text" } else { "input_text" };
|
||||
let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" };
|
||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||
}
|
||||
"image_url" => {
|
||||
// Assistant typically doesn't have image_url in history this way, but for safety:
|
||||
let new_type = if role == "assistant" { "output_image" } else { "input_image" };
|
||||
let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" };
|
||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||
if let Some(img_url) = part_obj.remove("image_url") {
|
||||
part_obj.insert("image".to_string(), img_url);
|
||||
@@ -144,14 +263,20 @@ impl super::Provider for OpenAIProvider {
|
||||
}
|
||||
}
|
||||
} else if let Some(text) = content.as_str() {
|
||||
let new_type = if role == "assistant" { "output_text" } else { "input_text" };
|
||||
content = serde_json::json!([{ "type": new_type, "text": text }]);
|
||||
// If it's just a string, send it as a string instead of an array of objects
|
||||
// as it's safer for standard conversational messages.
|
||||
content = serde_json::json!(text);
|
||||
}
|
||||
|
||||
input_parts.push(serde_json::json!({
|
||||
"role": role,
|
||||
let mut msg_item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": mapped_role,
|
||||
"content": content
|
||||
}));
|
||||
});
|
||||
if let Some(name) = m.get("name") {
|
||||
msg_item["name"] = name.clone();
|
||||
}
|
||||
input_parts.push(msg_item);
|
||||
}
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
@@ -174,7 +299,33 @@ impl super::Provider for OpenAIProvider {
|
||||
}
|
||||
|
||||
if let Some(tools) = &request.tools {
|
||||
body["tools"] = serde_json::json!(tools);
|
||||
let flattened: Vec<serde_json::Value> = tools.iter().map(|t| {
|
||||
let mut obj = serde_json::json!({
|
||||
"type": t.tool_type,
|
||||
"name": t.function.name,
|
||||
});
|
||||
if let Some(desc) = &t.function.description {
|
||||
obj["description"] = serde_json::json!(desc);
|
||||
}
|
||||
if let Some(params) = &t.function.parameters {
|
||||
obj["parameters"] = params.clone();
|
||||
}
|
||||
obj
|
||||
}).collect();
|
||||
body["tools"] = serde_json::json!(flattened);
|
||||
}
|
||||
if let Some(tool_choice) = &request.tool_choice {
|
||||
match tool_choice {
|
||||
crate::models::ToolChoice::Mode(mode) => {
|
||||
body["tool_choice"] = serde_json::json!(mode);
|
||||
}
|
||||
crate::models::ToolChoice::Specific(specific) => {
|
||||
body["tool_choice"] = serde_json::json!({
|
||||
"type": specific.choice_type,
|
||||
"name": specific.function.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = self
|
||||
@@ -200,18 +351,43 @@ impl super::Provider for OpenAIProvider {
|
||||
|
||||
// Normalize Responses API output into ProviderResponse
|
||||
let mut content_text = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
if let Some(output) = resp_json.get("output").and_then(|o| o.as_array()) {
|
||||
for out in output {
|
||||
if let Some(contents) = out.get("content").and_then(|c| c.as_array()) {
|
||||
for item in contents {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
if !content_text.is_empty() { content_text.push_str("\n"); }
|
||||
content_text.push_str(text);
|
||||
} else if let Some(parts) = item.get("parts").and_then(|p| p.as_array()) {
|
||||
for p in parts {
|
||||
if let Some(t) = p.as_str() {
|
||||
let item_type = out.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match item_type {
|
||||
"message" => {
|
||||
if let Some(contents) = out.get("content").and_then(|c| c.as_array()) {
|
||||
for item in contents {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
if !content_text.is_empty() { content_text.push_str("\n"); }
|
||||
content_text.push_str(t);
|
||||
content_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let id = out.get("call_id")
|
||||
.or_else(|| out.get("item_id"))
|
||||
.or_else(|| out.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = out.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let arguments = out.get("arguments").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
tool_calls.push(crate::models::ToolCall {
|
||||
id,
|
||||
call_type: "function".to_string(),
|
||||
function: crate::models::FunctionCall { name, arguments },
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Fallback for older/nested structure
|
||||
if let Some(contents) = out.get("content").and_then(|c| c.as_array()) {
|
||||
for item in contents {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
if !content_text.is_empty() { content_text.push_str("\n"); }
|
||||
content_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,10 +417,22 @@ impl super::Provider for OpenAIProvider {
|
||||
let completion_tokens = resp_json.get("usage").and_then(|u| u.get("completion_tokens")).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let total_tokens = resp_json.get("usage").and_then(|u| u.get("total_tokens")).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
// GPT-5.4 parallel tool calls might be embedded in content_text as a JSON block
|
||||
let embedded_calls = Self::parse_tool_uses_json(&content_text);
|
||||
if !embedded_calls.is_empty() {
|
||||
// Strip the JSON part from content_text to keep it clean
|
||||
if let Some(start) = content_text.find("{\"tool_uses\":") {
|
||||
content_text = content_text[..start].to_string();
|
||||
}
|
||||
tool_calls.extend(embedded_calls);
|
||||
}
|
||||
|
||||
content_text = Self::strip_internal_metadata(&content_text);
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content: content_text,
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens: 0,
|
||||
@@ -379,10 +567,57 @@ impl super::Provider for OpenAIProvider {
|
||||
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
||||
let mut input_parts = Vec::new();
|
||||
for m in &messages_json {
|
||||
let mut role = m["role"].as_str().unwrap_or("user").to_string();
|
||||
// Newer models (gpt-5, o1) prefer "developer" over "system"
|
||||
if role == "system" {
|
||||
role = "developer".to_string();
|
||||
let role = m["role"].as_str().unwrap_or("user");
|
||||
|
||||
if role == "tool" {
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"output": m.get("content").and_then(|v| v.as_str()).unwrap_or("")
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if role == "assistant" && m.get("tool_calls").is_some() {
|
||||
// Push message part if it exists
|
||||
let content_val = m.get("content").cloned().unwrap_or(serde_json::json!(""));
|
||||
if !content_val.is_null() && (content_val.is_array() && !content_val.as_array().unwrap().is_empty() || content_val.is_string() && !content_val.as_str().unwrap().is_empty()) {
|
||||
let mut content = content_val.clone();
|
||||
if let Some(text) = content.as_str() {
|
||||
content = serde_json::json!([{ "type": "output_text", "text": text }]);
|
||||
} else if let Some(arr) = content.as_array_mut() {
|
||||
for part in arr {
|
||||
if let Some(obj) = part.as_object_mut() {
|
||||
if obj.get("type").and_then(|v| v.as_str()) == Some("text") {
|
||||
obj.insert("type".to_string(), serde_json::json!("output_text"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
|
||||
// Push tool calls as separate items
|
||||
if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for tc in tcs {
|
||||
input_parts.push(serde_json::json!({
|
||||
"type": "function_call",
|
||||
"call_id": tc["id"],
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"]
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mapped_role = role.to_string();
|
||||
if mapped_role == "system" {
|
||||
mapped_role = "developer".to_string();
|
||||
}
|
||||
|
||||
let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([]));
|
||||
@@ -394,12 +629,11 @@ impl super::Provider for OpenAIProvider {
|
||||
if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
|
||||
match t {
|
||||
"text" => {
|
||||
let new_type = if role == "assistant" { "output_text" } else { "input_text" };
|
||||
let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" };
|
||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||
}
|
||||
"image_url" => {
|
||||
// Assistant typically doesn't have image_url in history this way, but for safety:
|
||||
let new_type = if role == "assistant" { "output_image" } else { "input_image" };
|
||||
let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" };
|
||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||
if let Some(img_url) = part_obj.remove("image_url") {
|
||||
part_obj.insert("image".to_string(), img_url);
|
||||
@@ -411,14 +645,20 @@ impl super::Provider for OpenAIProvider {
|
||||
}
|
||||
}
|
||||
} else if let Some(text) = content.as_str() {
|
||||
let new_type = if role == "assistant" { "output_text" } else { "input_text" };
|
||||
content = serde_json::json!([{ "type": new_type, "text": text }]);
|
||||
// If it's just a string, send it as a string instead of an array of objects
|
||||
// as it's safer for standard conversational messages.
|
||||
content = serde_json::json!(text);
|
||||
}
|
||||
|
||||
input_parts.push(serde_json::json!({
|
||||
"role": role,
|
||||
let mut msg_item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": mapped_role,
|
||||
"content": content
|
||||
}));
|
||||
});
|
||||
if let Some(name) = m.get("name") {
|
||||
msg_item["name"] = name.clone();
|
||||
}
|
||||
input_parts.push(msg_item);
|
||||
}
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
@@ -441,6 +681,36 @@ impl super::Provider for OpenAIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tools) = &request.tools {
|
||||
let flattened: Vec<serde_json::Value> = tools.iter().map(|t| {
|
||||
let mut obj = serde_json::json!({
|
||||
"type": t.tool_type,
|
||||
"name": t.function.name,
|
||||
});
|
||||
if let Some(desc) = &t.function.description {
|
||||
obj["description"] = serde_json::json!(desc);
|
||||
}
|
||||
if let Some(params) = &t.function.parameters {
|
||||
obj["parameters"] = params.clone();
|
||||
}
|
||||
obj
|
||||
}).collect();
|
||||
body["tools"] = serde_json::json!(flattened);
|
||||
}
|
||||
if let Some(tool_choice) = &request.tool_choice {
|
||||
match tool_choice {
|
||||
crate::models::ToolChoice::Mode(mode) => {
|
||||
body["tool_choice"] = serde_json::json!(mode);
|
||||
}
|
||||
crate::models::ToolChoice::Specific(specific) => {
|
||||
body["tool_choice"] = serde_json::json!({
|
||||
"type": specific.choice_type,
|
||||
"name": specific.function.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("{}/responses", self.config.base_url);
|
||||
let api_key = self.api_key.clone();
|
||||
let model = request.model.clone();
|
||||
@@ -458,6 +728,8 @@ impl super::Provider for OpenAIProvider {
|
||||
|
||||
let stream = async_stream::try_stream! {
|
||||
let mut es = es;
|
||||
let mut content_buffer = String::new();
|
||||
|
||||
while let Some(event) = es.next().await {
|
||||
match event {
|
||||
Ok(reqwest_eventsource::Event::Message(msg)) => {
|
||||
@@ -473,53 +745,141 @@ impl super::Provider for OpenAIProvider {
|
||||
yield p_chunk?;
|
||||
} else {
|
||||
// Responses API specific parsing for streaming
|
||||
let mut content = String::new();
|
||||
let mut finish_reason = None;
|
||||
let mut tool_calls = None;
|
||||
|
||||
let event_type = chunk.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = chunk.get("delta").and_then(|v| v.as_str()) {
|
||||
content.push_str(delta);
|
||||
content_buffer.push_str(delta);
|
||||
}
|
||||
}
|
||||
"response.output_text.done" => {
|
||||
if let Some(text) = chunk.get("text").and_then(|v| v.as_str()) {
|
||||
// Some implementations send the full text at the end
|
||||
// We usually prefer deltas, but if we haven't seen them, this is the fallback.
|
||||
// However, if we're already yielding deltas, we might not want this.
|
||||
// For now, let's just use it as a signal that we're done.
|
||||
finish_reason = Some("stop".to_string());
|
||||
}
|
||||
}
|
||||
"response.done" => {
|
||||
finish_reason = Some("stop".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Fallback to older nested structure if present
|
||||
if let Some(output) = chunk.get("output").and_then(|o| o.as_array()) {
|
||||
for out in output {
|
||||
if let Some(contents) = out.get("content").and_then(|c| c.as_array()) {
|
||||
for item in contents {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
content.push_str(text);
|
||||
} else if let Some(delta) = item.get("delta").and_then(|d| d.get("text")).and_then(|t| t.as_str()) {
|
||||
content.push_str(delta);
|
||||
}
|
||||
}
|
||||
"response.item.delta" => {
|
||||
if let Some(delta) = chunk.get("delta") {
|
||||
let t = delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if t == "function_call" {
|
||||
let call_id = delta.get("call_id")
|
||||
.or_else(|| chunk.get("item_id"))
|
||||
.and_then(|v| v.as_str());
|
||||
let name = delta.get("name").and_then(|v| v.as_str());
|
||||
let arguments = delta.get("arguments").and_then(|v| v.as_str());
|
||||
|
||||
tool_calls = Some(vec![crate::models::ToolCallDelta {
|
||||
index: chunk.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
id: call_id.map(|s| s.to_string()),
|
||||
call_type: Some("function".to_string()),
|
||||
function: Some(crate::models::FunctionCallDelta {
|
||||
name: name.map(|s| s.to_string()),
|
||||
arguments: arguments.map(|s| s.to_string()),
|
||||
}),
|
||||
}]);
|
||||
} else if t == "message" {
|
||||
if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
|
||||
content_buffer.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.output_text.done" | "response.item.done" | "response.done" => {
|
||||
finish_reason = Some("stop".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !content.is_empty() || finish_reason.is_some() {
|
||||
// Process content_buffer to extract embedded tool calls or yield text
|
||||
if !content_buffer.is_empty() {
|
||||
// If we see the start of a tool call block, we wait for the full block
|
||||
if content_buffer.contains("{\"tool_uses\":") {
|
||||
let embedded_calls = Self::parse_tool_uses_json(&content_buffer);
|
||||
if !embedded_calls.is_empty() {
|
||||
if let Some(start) = content_buffer.find("{\"tool_uses\":") {
|
||||
// Yield text before the JSON block
|
||||
let preamble = content_buffer[..start].to_string();
|
||||
let stripped_preamble = Self::strip_internal_metadata(&preamble);
|
||||
if !stripped_preamble.is_empty() {
|
||||
yield ProviderStreamChunk {
|
||||
content: stripped_preamble,
|
||||
reasoning_content: None,
|
||||
finish_reason: None,
|
||||
tool_calls: None,
|
||||
model: model.clone(),
|
||||
usage: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Yield the tool calls
|
||||
// ... (rest of tool call yielding unchanged)
|
||||
let deltas: Vec<crate::models::ToolCallDelta> = embedded_calls.into_iter().enumerate().map(|(idx, tc)| {
|
||||
crate::models::ToolCallDelta {
|
||||
index: idx as u32,
|
||||
id: Some(tc.id),
|
||||
call_type: Some("function".to_string()),
|
||||
function: Some(crate::models::FunctionCallDelta {
|
||||
name: Some(tc.function.name),
|
||||
arguments: Some(tc.function.arguments),
|
||||
}),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
yield ProviderStreamChunk {
|
||||
content: String::new(),
|
||||
reasoning_content: None,
|
||||
finish_reason: None,
|
||||
tool_calls: Some(deltas),
|
||||
model: model.clone(),
|
||||
usage: None,
|
||||
};
|
||||
|
||||
// Remove the processed part from buffer
|
||||
// We need to find the end index correctly
|
||||
let sub = &content_buffer[start..];
|
||||
let mut brace_count = 0;
|
||||
let mut end_idx = 0;
|
||||
for (i, c) in sub.char_indices() {
|
||||
if c == '{' { brace_count += 1; }
|
||||
else if c == '}' {
|
||||
brace_count -= 1;
|
||||
if brace_count == 0 {
|
||||
end_idx = start + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if end_idx > 0 {
|
||||
content_buffer = content_buffer[end_idx..].to_string();
|
||||
} else {
|
||||
content_buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we have "{"tool_uses":" but no full block yet, we just wait (don't yield)
|
||||
} else if content_buffer.contains("to=multi_tool_use.parallel") {
|
||||
// Wait for the JSON block that usually follows
|
||||
} else {
|
||||
// Standard text, yield and clear buffer
|
||||
let content = std::mem::take(&mut content_buffer);
|
||||
let stripped_content = Self::strip_internal_metadata(&content);
|
||||
if !stripped_content.is_empty() {
|
||||
yield ProviderStreamChunk {
|
||||
content: stripped_content,
|
||||
reasoning_content: None,
|
||||
finish_reason: None,
|
||||
tool_calls: None,
|
||||
model: model.clone(),
|
||||
usage: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if finish_reason.is_some() || tool_calls.is_some() {
|
||||
yield ProviderStreamChunk {
|
||||
content,
|
||||
content: String::new(),
|
||||
reasoning_content: None,
|
||||
finish_reason,
|
||||
tool_calls: None,
|
||||
tool_calls,
|
||||
model: model.clone(),
|
||||
usage: None,
|
||||
};
|
||||
@@ -529,11 +889,16 @@ impl super::Provider for OpenAIProvider {
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
// Attempt to probe for the actual error body
|
||||
let mut probe_body_no_stream = probe_body.clone();
|
||||
if let Some(obj) = probe_body_no_stream.as_object_mut() {
|
||||
obj.remove("stream");
|
||||
}
|
||||
|
||||
let probe_resp = probe_client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Accept", "application/json") // Ask for JSON during probe
|
||||
.json(&probe_body)
|
||||
.header("Accept", "application/json")
|
||||
.json(&probe_body_no_stream)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user