mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
github_copilot /responses API (#9043)
Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -1,16 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use async_stream::try_stream;
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::pin;
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use super::api_client::{ApiClient, AuthMethod, AuthProvider};
|
||||
use super::base::{
|
||||
@@ -20,13 +14,11 @@ use super::base::{
|
||||
use super::embedding::EmbeddingCapable;
|
||||
use super::errors::ProviderError;
|
||||
use super::formats::databricks::create_request;
|
||||
use super::formats::openai_responses::{
|
||||
create_responses_request, responses_api_to_streaming_message,
|
||||
};
|
||||
use super::formats::openai_responses::create_responses_request;
|
||||
use super::oauth;
|
||||
use super::openai_compatible::{
|
||||
handle_response_openai_compat, handle_status, map_http_error_to_provider_error,
|
||||
stream_openai_compat,
|
||||
stream_openai_compat, stream_responses_compat,
|
||||
};
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::{ImageFormat, RequestLog};
|
||||
@@ -412,20 +404,7 @@ impl Provider for DatabricksProvider {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
let stream = response.bytes_stream().map_err(io::Error::other);
|
||||
|
||||
Ok(Box::pin(try_stream! {
|
||||
let stream_reader = StreamReader::new(stream);
|
||||
let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from);
|
||||
|
||||
let message_stream = responses_api_to_streaming_message(framed);
|
||||
pin!(message_stream);
|
||||
while let Some(message) = message_stream.next().await {
|
||||
let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?;
|
||||
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
|
||||
yield (message, usage);
|
||||
}
|
||||
}))
|
||||
stream_responses_compat(response, log)
|
||||
} else {
|
||||
let mut payload =
|
||||
create_request(model_config, system, messages, tools, &self.image_format)?;
|
||||
|
||||
@@ -79,6 +79,9 @@ pub enum ResponseContentBlock {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Vec<Value>>,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -187,6 +190,22 @@ pub enum ResponsesStreamEvent {
|
||||
output_index: i32,
|
||||
arguments: String,
|
||||
},
|
||||
#[serde(rename = "response.refusal.delta")]
|
||||
RefusalDelta {
|
||||
sequence_number: i32,
|
||||
item_id: String,
|
||||
output_index: i32,
|
||||
content_index: i32,
|
||||
delta: String,
|
||||
},
|
||||
#[serde(rename = "response.refusal.done")]
|
||||
RefusalDone {
|
||||
sequence_number: i32,
|
||||
item_id: String,
|
||||
output_index: i32,
|
||||
content_index: i32,
|
||||
refusal: String,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error { error: Value },
|
||||
#[serde(rename = "keepalive")]
|
||||
@@ -211,6 +230,8 @@ fn is_known_responses_stream_event_type(event_type: &str) -> bool {
|
||||
| "response.failed"
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.function_call_arguments.done"
|
||||
| "response.refusal.delta"
|
||||
| "response.refusal.done"
|
||||
| "error"
|
||||
| "keepalive"
|
||||
)
|
||||
@@ -293,6 +314,9 @@ pub enum ContentPart {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
logprobs: Option<Vec<Value>>,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -331,28 +355,43 @@ fn add_message_items(input_items: &mut Vec<Value>, messages: &[Message]) {
|
||||
text_items = Vec::new();
|
||||
}
|
||||
|
||||
if let Ok(tool_call) = &request.tool_call {
|
||||
let arguments_str = tool_call
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|args| {
|
||||
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
match &request.tool_call {
|
||||
Ok(tool_call) => {
|
||||
let arguments_str = tool_call
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|args| {
|
||||
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
tracing::debug!(
|
||||
"Replaying function_call with call_id: {}, name: {}",
|
||||
request.id,
|
||||
tool_call.name
|
||||
);
|
||||
input_items.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": request.id,
|
||||
"name": tool_call.name,
|
||||
"arguments": arguments_str
|
||||
}));
|
||||
tracing::debug!(
|
||||
"Replaying function_call with call_id: {}, name: {}",
|
||||
request.id,
|
||||
tool_call.name
|
||||
);
|
||||
input_items.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": request.id,
|
||||
"name": tool_call.name,
|
||||
"arguments": arguments_str
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
input_items.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": request.id,
|
||||
"output": format!("Error: {}", e.message)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::Image(image) => {
|
||||
text_items.push(json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:{};base64,{}", image.mime_type, image.data)
|
||||
}));
|
||||
}
|
||||
MessageContent::ToolResponse(response) => {
|
||||
if !text_items.is_empty() {
|
||||
input_items.push(json!({
|
||||
@@ -434,6 +473,41 @@ fn add_message_items(input_items: &mut Vec<Value>, messages: &[Message]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::FrontendToolRequest(request) => {
|
||||
if !text_items.is_empty() {
|
||||
input_items.push(json!({
|
||||
"role": role,
|
||||
"content": text_items
|
||||
}));
|
||||
text_items = Vec::new();
|
||||
}
|
||||
|
||||
match &request.tool_call {
|
||||
Ok(tool_call) => {
|
||||
let arguments_str = tool_call
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|args| {
|
||||
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
input_items.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": request.id,
|
||||
"name": tool_call.name,
|
||||
"arguments": arguments_str
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
input_items.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": request.id,
|
||||
"output": format!("Error: {}", e.message)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -545,6 +619,11 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu
|
||||
content.push(MessageContent::text(text));
|
||||
}
|
||||
}
|
||||
ResponseContentBlock::Refusal { refusal } => {
|
||||
if !refusal.is_empty() {
|
||||
content.push(MessageContent::text(refusal));
|
||||
}
|
||||
}
|
||||
ResponseContentBlock::ToolCall { id, name, input } => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id.clone(),
|
||||
@@ -614,6 +693,11 @@ fn process_streaming_output_items(
|
||||
content.push(MessageContent::text(&text));
|
||||
}
|
||||
}
|
||||
ContentPart::Refusal { refusal } => {
|
||||
if !refusal.is_empty() && !is_text_response {
|
||||
content.push(MessageContent::text(&refusal));
|
||||
}
|
||||
}
|
||||
ContentPart::ToolCall {
|
||||
id,
|
||||
name,
|
||||
@@ -777,6 +861,29 @@ where
|
||||
// Arguments are complete, will be in the OutputItemDone event
|
||||
}
|
||||
|
||||
ResponsesStreamEvent::RefusalDelta { delta, .. } => {
|
||||
is_text_response = true;
|
||||
if !delta.is_empty() {
|
||||
accumulated_text.push_str(&delta);
|
||||
|
||||
let mut msg = Message::new(
|
||||
Role::Assistant,
|
||||
chrono::Utc::now().timestamp(),
|
||||
vec![MessageContent::text(&delta)],
|
||||
);
|
||||
|
||||
if let Some(id) = &response_id {
|
||||
msg = msg.with_id(id.clone());
|
||||
}
|
||||
|
||||
yield (Some(msg), None);
|
||||
}
|
||||
}
|
||||
|
||||
ResponsesStreamEvent::RefusalDone { .. } => {
|
||||
// Refusal text already streamed via deltas
|
||||
}
|
||||
|
||||
ResponsesStreamEvent::ResponseFailed { error, .. } => {
|
||||
Err(anyhow!("Responses API failed: {:?}", error))?;
|
||||
}
|
||||
@@ -1186,4 +1293,543 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_image_serialized_in_responses_request() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::user()
|
||||
.with_text("describe this image")
|
||||
.with_image("aW1hZ2VkYXRh", "image/png")];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result =
|
||||
create_responses_request(&model_config, "You are helpful.", &messages, &[]).unwrap();
|
||||
|
||||
let input = result["input"].as_array().unwrap();
|
||||
assert_eq!(input.len(), 2);
|
||||
|
||||
assert_eq!(input[0]["role"], "system");
|
||||
|
||||
assert_eq!(input[1]["role"], "user");
|
||||
let content = input[1]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
assert_eq!(content[0]["text"], "describe this image");
|
||||
|
||||
assert_eq!(content[1]["type"], "input_image");
|
||||
assert_eq!(
|
||||
content[1]["image_url"],
|
||||
"data:image/png;base64,aW1hZ2VkYXRh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_response_with_image_serializes_as_typed_array() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
||||
let messages = vec![Message::user().with_content(MessageContent::tool_response(
|
||||
"call_1",
|
||||
Ok(CallToolResult::success(vec![
|
||||
Content::text("caption"),
|
||||
Content::image("a+/=".to_string(), "image/png".to_string()),
|
||||
])),
|
||||
))];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["type"], "function_call_output");
|
||||
assert_eq!(input[0]["call_id"], "call_1");
|
||||
|
||||
let output = input[0]["output"].as_array().unwrap();
|
||||
assert_eq!(output.len(), 2);
|
||||
assert_eq!(output[0], json!({"type": "input_text", "text": "caption"}));
|
||||
assert_eq!(
|
||||
output[1],
|
||||
json!({"type": "input_image", "image_url": "data:image/png;base64,a+/="})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_request_serializes_function_call_with_arguments() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::assistant().with_tool_request(
|
||||
"call_1",
|
||||
Ok(CallToolRequestParams::new("search")
|
||||
.with_arguments(object!({"q": "rust", "limit": 2}))),
|
||||
)];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["type"], "function_call");
|
||||
assert_eq!(input[0]["call_id"], "call_1");
|
||||
assert_eq!(input[0]["name"], "search");
|
||||
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(input[0]["arguments"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(args["q"], "rust");
|
||||
assert_eq!(args["limit"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_request_none_arguments_serializes_empty_object() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::assistant()
|
||||
.with_tool_request("call_1", Ok(CallToolRequestParams::new("noop")))];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["type"], "function_call");
|
||||
assert_eq!(input[0]["name"], "noop");
|
||||
assert_eq!(input[0]["arguments"], "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_flushed_before_tool_request() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::assistant()
|
||||
.with_text("planning")
|
||||
.with_tool_request(
|
||||
"call_1",
|
||||
Ok(CallToolRequestParams::new("shell").with_arguments(object!({"command": "ls"}))),
|
||||
)];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input.len(), 2);
|
||||
assert_eq!(input[0]["role"], "assistant");
|
||||
assert_eq!(input[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input[0]["content"][0]["text"], "planning");
|
||||
assert_eq!(input[1]["type"], "function_call");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_flushed_before_tool_response() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
||||
let messages =
|
||||
vec![Message::user()
|
||||
.with_text("context")
|
||||
.with_content(MessageContent::tool_response(
|
||||
"call_1",
|
||||
Ok(CallToolResult::success(vec![Content::text("done")])),
|
||||
))];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input.len(), 2);
|
||||
assert_eq!(input[0]["role"], "user");
|
||||
assert_eq!(input[0]["content"][0]["type"], "input_text");
|
||||
assert_eq!(input[0]["content"][0]["text"], "context");
|
||||
assert_eq!(input[1]["type"], "function_call_output");
|
||||
assert_eq!(input[1]["output"], "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_response_error_serializes_with_error_prefix() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{ErrorCode, ErrorData};
|
||||
|
||||
let messages = vec![Message::user().with_content(MessageContent::tool_response(
|
||||
"call_err",
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INTERNAL_ERROR,
|
||||
message: "file not found".into(),
|
||||
data: None,
|
||||
}),
|
||||
))];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["type"], "function_call_output");
|
||||
assert_eq!(input[0]["call_id"], "call_err");
|
||||
assert_eq!(input[0]["output"], "Error: file not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_only_message_serializes() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::user().with_image("aW1n", "image/png")];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input.len(), 1);
|
||||
assert_eq!(input[0]["role"], "user");
|
||||
let content = input[0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "input_image");
|
||||
assert_eq!(content[0]["image_url"], "data:image/png;base64,aW1n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_images_preserved_in_order() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::user()
|
||||
.with_text("compare")
|
||||
.with_image("img1", "image/png")
|
||||
.with_image("img2", "image/jpeg")];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["role"], "user");
|
||||
let content = input[0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 3);
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
assert_eq!(content[0]["text"], "compare");
|
||||
assert_eq!(content[1]["type"], "input_image");
|
||||
assert_eq!(content[1]["image_url"], "data:image/png;base64,img1");
|
||||
assert_eq!(content[2]["type"], "input_image");
|
||||
assert_eq!(content[2]["image_url"], "data:image/jpeg;base64,img2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_text_uses_output_text_type() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages = vec![Message::assistant().with_text("hello")];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["role"], "assistant");
|
||||
assert_eq!(input[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input[0]["content"][0]["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refusal_content_block_deserializes_in_non_streaming_response() {
|
||||
let json = r#"{
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "completed",
|
||||
"model": "gpt-5.5",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "refusal", "refusal": "I cannot help with that request."}]
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let response: ResponsesApiResponse = serde_json::from_str(json).unwrap();
|
||||
let message = responses_api_to_message(&response).unwrap();
|
||||
assert_eq!(message.content.len(), 1);
|
||||
if let MessageContent::Text(t) = &message.content[0] {
|
||||
assert_eq!(t.text, "I cannot help with that request.");
|
||||
} else {
|
||||
panic!("expected text content from refusal");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refusal_content_part_deserializes_in_streaming_output() {
|
||||
let json = r#"{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "refusal", "refusal": "I'm unable to assist."}]
|
||||
}"#;
|
||||
|
||||
let item: ResponseOutputItemInfo = serde_json::from_str(json).unwrap();
|
||||
let content = process_streaming_output_items(vec![item], false);
|
||||
assert_eq!(content.len(), 1);
|
||||
if let MessageContent::Text(t) = &content[0] {
|
||||
assert_eq!(t.text, "I'm unable to assist.");
|
||||
} else {
|
||||
panic!("expected text content from refusal");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refusal_delta_stream_event_deserializes() {
|
||||
let json = r#"{"type":"response.refusal.delta","sequence_number":5,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"I cannot"}"#;
|
||||
|
||||
let event: ResponsesStreamEvent = serde_json::from_str(json).unwrap();
|
||||
match event {
|
||||
ResponsesStreamEvent::RefusalDelta { delta, .. } => {
|
||||
assert_eq!(delta, "I cannot");
|
||||
}
|
||||
_ => panic!("expected RefusalDelta event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streamed_refusal_not_duplicated_in_output_items() {
|
||||
let output_items = vec![ResponseOutputItemInfo::Message {
|
||||
id: "msg_1".to_string(),
|
||||
status: "completed".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentPart::Refusal {
|
||||
refusal: "I cannot help with that.".to_string(),
|
||||
}],
|
||||
}];
|
||||
|
||||
let content = process_streaming_output_items(output_items.clone(), true);
|
||||
assert!(
|
||||
content.is_empty(),
|
||||
"refusal should be suppressed when already streamed"
|
||||
);
|
||||
|
||||
let content = process_streaming_output_items(output_items, false);
|
||||
assert_eq!(
|
||||
content.len(),
|
||||
1,
|
||||
"refusal should appear in non-streaming path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frontend_tool_request_serialized_in_responses_request() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
||||
let messages = vec![
|
||||
Message::assistant().with_frontend_tool_request(
|
||||
"call_ft1",
|
||||
Ok(CallToolRequestParams::new("browser_click")
|
||||
.with_arguments(object!({"selector": "#btn"}))),
|
||||
),
|
||||
Message::user().with_content(MessageContent::tool_response(
|
||||
"call_ft1",
|
||||
Ok(CallToolResult::success(vec![Content::text("clicked")])),
|
||||
)),
|
||||
];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input[0]["type"], "function_call");
|
||||
assert_eq!(input[0]["call_id"], "call_ft1");
|
||||
assert_eq!(input[0]["name"], "browser_click");
|
||||
|
||||
assert_eq!(input[1]["type"], "function_call_output");
|
||||
assert_eq!(input[1]["call_id"], "call_ft1");
|
||||
assert_eq!(input[1]["output"], "clicked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_request_error_emits_function_call_output() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{ErrorCode, ErrorData};
|
||||
|
||||
let messages = vec![Message::assistant().with_tool_request(
|
||||
"call_err1",
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INTERNAL_ERROR,
|
||||
message: "invalid arguments".into(),
|
||||
data: None,
|
||||
}),
|
||||
)];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input.len(), 1);
|
||||
assert_eq!(input[0]["type"], "function_call_output");
|
||||
assert_eq!(input[0]["call_id"], "call_err1");
|
||||
assert!(input[0]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("invalid arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frontend_tool_request_error_emits_function_call_output() {
|
||||
use crate::conversation::message::Message;
|
||||
use rmcp::model::{ErrorCode, ErrorData};
|
||||
|
||||
let messages = vec![Message::assistant().with_frontend_tool_request(
|
||||
"call_ft_err",
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INTERNAL_ERROR,
|
||||
message: "malformed arguments".into(),
|
||||
data: None,
|
||||
}),
|
||||
)];
|
||||
|
||||
let model_config = ModelConfig {
|
||||
model_name: "gpt-5.5".to_string(),
|
||||
context_limit: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
toolshim: false,
|
||||
toolshim_model: None,
|
||||
fast_model_config: None,
|
||||
request_params: None,
|
||||
reasoning: None,
|
||||
};
|
||||
|
||||
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
|
||||
let input = result["input"].as_array().unwrap();
|
||||
|
||||
assert_eq!(input.len(), 1);
|
||||
assert_eq!(input[0]["type"], "function_call_output");
|
||||
assert_eq!(input[0]["call_id"], "call_ft_err");
|
||||
assert!(input[0]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("malformed arguments"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::config::paths::Paths;
|
||||
use crate::providers::api_client::{ApiClient, AuthMethod};
|
||||
use crate::providers::oauth_device_flow::{run_device_flow, DeviceFlowConfig, RequestEncoding};
|
||||
use crate::providers::openai_compatible::{handle_status, stream_openai_compat};
|
||||
use crate::providers::openai_compatible::{
|
||||
handle_status, stream_openai_compat, stream_responses_compat,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::http;
|
||||
@@ -25,9 +27,10 @@ use super::base::{
|
||||
};
|
||||
use super::errors::ProviderError;
|
||||
use super::formats::openai::{create_request, get_usage, response_to_message};
|
||||
use super::formats::openai_responses::create_responses_request;
|
||||
use super::openai_compatible::handle_response_openai_compat;
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::{get_model, ImageFormat, RequestLog};
|
||||
use super::utils::{get_model, is_openai_responses_model, ImageFormat, RequestLog};
|
||||
|
||||
use crate::config::{Config, ConfigError};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
@@ -35,30 +38,43 @@ use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::base::{ConfigKey, MessageStream};
|
||||
use futures::future::BoxFuture;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::{RawContent, Tool};
|
||||
use std::ops::Deref;
|
||||
|
||||
const GITHUB_COPILOT_PROVIDER_NAME: &str = "github_copilot";
|
||||
pub const GITHUB_COPILOT_DEFAULT_MODEL: &str = "gpt-4.1";
|
||||
pub const GITHUB_COPILOT_KNOWN_MODELS: &[&str] = &[
|
||||
"gpt-4.1",
|
||||
"gpt-5-mini",
|
||||
"gpt-5",
|
||||
"gpt-4o",
|
||||
"grok-code-fast-1",
|
||||
"gpt-5-codex",
|
||||
"claude-haiku-4.5",
|
||||
"claude-opus-4.5",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4.7",
|
||||
"claude-sonnet-4",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"claude-sonnet-4.6",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"grok-code-fast-1",
|
||||
"gpt-5-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.5",
|
||||
];
|
||||
|
||||
// Models that support streaming on the /chat/completions path.
|
||||
// Models routed to /responses always stream and don't need to be listed here.
|
||||
pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = &[
|
||||
"gpt-4.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-codex",
|
||||
"gemini-2.5-pro",
|
||||
"gpt-4o",
|
||||
"grok-code-fast-1",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-pro-preview",
|
||||
];
|
||||
|
||||
const GITHUB_COPILOT_DOC_URL: &str =
|
||||
@@ -196,27 +212,18 @@ impl GithubCopilotProvider {
|
||||
DiskCache::new(&host).clear().await
|
||||
}
|
||||
|
||||
fn payload_contains_image(payload: &Value) -> bool {
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.is_some_and(|messages| {
|
||||
messages.iter().any(|msg| {
|
||||
msg.get("content").is_some_and(|content| {
|
||||
content
|
||||
.as_array()
|
||||
.map(|arr| arr.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec![content])
|
||||
.iter()
|
||||
.any(|item| {
|
||||
matches!(
|
||||
item.get("type").and_then(|v| v.as_str()),
|
||||
Some("image_url") | Some("image")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
fn messages_contain_image(messages: &[Message]) -> bool {
|
||||
messages.iter().any(|m| {
|
||||
m.content.iter().any(|c| match c {
|
||||
MessageContent::Image(_) => true,
|
||||
MessageContent::ToolResponse(resp) => resp.tool_result.as_ref().is_ok_and(|r| {
|
||||
r.content
|
||||
.iter()
|
||||
.any(|item| matches!(item.deref(), RawContent::Image(_)))
|
||||
}),
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn from_env(model: ModelConfig) -> Result<Self> {
|
||||
@@ -250,13 +257,15 @@ impl GithubCopilotProvider {
|
||||
async fn post(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
path: &str,
|
||||
is_user_initiated: bool,
|
||||
payload: &mut Value,
|
||||
has_images: bool,
|
||||
) -> Result<Response, ProviderError> {
|
||||
let (endpoint, token) = self.get_api_info().await?;
|
||||
let auth = AuthMethod::BearerToken(token);
|
||||
let mut headers = self.get_github_headers();
|
||||
if Self::payload_contains_image(payload) {
|
||||
if has_images {
|
||||
headers.insert("Copilot-Vision-Request", "true".parse().unwrap());
|
||||
}
|
||||
let initiator = if is_user_initiated { "user" } else { "agent" };
|
||||
@@ -264,7 +273,7 @@ impl GithubCopilotProvider {
|
||||
let api_client = ApiClient::new(endpoint.clone(), auth)?.with_headers(headers)?;
|
||||
|
||||
api_client
|
||||
.response_post(session_id, "chat/completions", payload)
|
||||
.response_post(session_id, path, payload)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
@@ -376,6 +385,139 @@ impl GithubCopilotProvider {
|
||||
headers.insert("editor-plugin-version", "copilot/1.155.0".parse().unwrap());
|
||||
headers
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn stream_responses(
|
||||
&self,
|
||||
model_config: &ModelConfig,
|
||||
session_id: &str,
|
||||
is_user_initiated: bool,
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
has_images: bool,
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let mut payload = create_responses_request(model_config, system, messages, tools)
|
||||
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
|
||||
payload["stream"] = serde_json::Value::Bool(true);
|
||||
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let mut payload_clone = payload.clone();
|
||||
let resp = self
|
||||
.post(
|
||||
Some(session_id),
|
||||
"responses",
|
||||
is_user_initiated,
|
||||
&mut payload_clone,
|
||||
has_images,
|
||||
)
|
||||
.await?;
|
||||
handle_status(resp).await
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
stream_responses_compat(response, log)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn stream_chat_completions(
|
||||
&self,
|
||||
model_config: &ModelConfig,
|
||||
session_id: &str,
|
||||
is_user_initiated: bool,
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
has_images: bool,
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let supports_streaming = GITHUB_COPILOT_STREAM_MODELS
|
||||
.iter()
|
||||
.any(|prefix| model_config.model_name.starts_with(prefix));
|
||||
|
||||
if supports_streaming {
|
||||
let payload = create_request(
|
||||
model_config,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
&ImageFormat::OpenAi,
|
||||
true,
|
||||
)?;
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let mut payload_clone = payload.clone();
|
||||
let resp = self
|
||||
.post(
|
||||
Some(session_id),
|
||||
"chat/completions",
|
||||
is_user_initiated,
|
||||
&mut payload_clone,
|
||||
has_images,
|
||||
)
|
||||
.await?;
|
||||
handle_status(resp).await
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
stream_openai_compat(response, log)
|
||||
} else {
|
||||
let session_id_opt = if session_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id)
|
||||
};
|
||||
let payload = create_request(
|
||||
model_config,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
&ImageFormat::OpenAi,
|
||||
false,
|
||||
)?;
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let mut payload_clone = payload.clone();
|
||||
self.post(
|
||||
session_id_opt,
|
||||
"chat/completions",
|
||||
is_user_initiated,
|
||||
&mut payload_clone,
|
||||
has_images,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
let response = handle_response_openai_compat(response).await?;
|
||||
|
||||
let response = promote_tool_choice(response);
|
||||
|
||||
let message = response_to_message(&response)?;
|
||||
let usage = response.get("usage").map(get_usage).unwrap_or_else(|| {
|
||||
tracing::debug!("Failed to get usage data");
|
||||
Usage::default()
|
||||
});
|
||||
let response_model = get_model(&response);
|
||||
log.write(&response, Some(&usage))?;
|
||||
|
||||
Ok(super::base::stream_from_single_message(
|
||||
message,
|
||||
ProviderUsage::new(response_model, usage),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDef for GithubCopilotProvider {
|
||||
@@ -416,7 +558,6 @@ impl Provider for GithubCopilotProvider {
|
||||
self.model.clone()
|
||||
}
|
||||
|
||||
// complete_fast() (compaction, title generation) calls this — always agent-initiated.
|
||||
#[tracing::instrument(
|
||||
skip(self, model_config, session_id, system, messages, tools),
|
||||
fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name)
|
||||
@@ -455,80 +596,30 @@ impl Provider for GithubCopilotProvider {
|
||||
.any(|c| matches!(c, MessageContent::ToolResponse(_)))
|
||||
});
|
||||
let is_user_initiated = !is_agent_call && !last_is_tool_response;
|
||||
let has_images = Self::messages_contain_image(messages);
|
||||
|
||||
// Check if this model supports streaming
|
||||
let supports_streaming = GITHUB_COPILOT_STREAM_MODELS
|
||||
.iter()
|
||||
.any(|prefix| model_config.model_name.starts_with(prefix));
|
||||
|
||||
if supports_streaming {
|
||||
// Use streaming API
|
||||
let payload = create_request(
|
||||
if is_openai_responses_model(&model_config.model_name) {
|
||||
self.stream_responses(
|
||||
model_config,
|
||||
session_id,
|
||||
is_user_initiated,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
&ImageFormat::OpenAi,
|
||||
true,
|
||||
)?;
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let mut payload_clone = payload.clone();
|
||||
let resp = self
|
||||
.post(Some(session_id), is_user_initiated, &mut payload_clone)
|
||||
.await?;
|
||||
handle_status(resp).await
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
stream_openai_compat(response, log)
|
||||
has_images,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
// Use non-streaming API and wrap result
|
||||
let session_id_opt = if session_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id)
|
||||
};
|
||||
let payload = create_request(
|
||||
self.stream_chat_completions(
|
||||
model_config,
|
||||
session_id,
|
||||
is_user_initiated,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
&ImageFormat::OpenAi,
|
||||
false,
|
||||
)?;
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
// Make request with retry
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let mut payload_clone = payload.clone();
|
||||
self.post(session_id_opt, is_user_initiated, &mut payload_clone)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
let response = handle_response_openai_compat(response).await?;
|
||||
|
||||
let response = promote_tool_choice(response);
|
||||
|
||||
// Parse response
|
||||
let message = response_to_message(&response)?;
|
||||
let usage = response.get("usage").map(get_usage).unwrap_or_else(|| {
|
||||
tracing::debug!("Failed to get usage data");
|
||||
Usage::default()
|
||||
});
|
||||
let response_model = get_model(&response);
|
||||
log.write(&response, Some(&usage))?;
|
||||
|
||||
Ok(super::base::stream_from_single_message(
|
||||
message,
|
||||
ProviderUsage::new(response_model, usage),
|
||||
))
|
||||
has_images,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,25 +667,20 @@ impl Provider for GithubCopilotProvider {
|
||||
async fn configure_oauth(&self) -> Result<(), ProviderError> {
|
||||
let config = Config::global();
|
||||
|
||||
// Check if token already exists and is valid
|
||||
if config.get_secret::<String>("GITHUB_COPILOT_TOKEN").is_ok() {
|
||||
// Try to refresh API info to validate the token
|
||||
match self.refresh_api_info().await {
|
||||
Ok(_) => return Ok(()), // Token is valid
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => {
|
||||
// Token is invalid, continue with OAuth flow
|
||||
tracing::debug!("Existing token is invalid, starting OAuth flow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start OAuth device code flow
|
||||
let token = self
|
||||
.get_access_token()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?;
|
||||
|
||||
// Save the token
|
||||
config
|
||||
.set_secret("GITHUB_COPILOT_TOKEN", &token)
|
||||
.map_err(|e| ProviderError::ExecutionError(format!("Failed to save token: {}", e)))?;
|
||||
@@ -605,10 +691,6 @@ impl Provider for GithubCopilotProvider {
|
||||
|
||||
// Copilot sometimes returns multiple choices in a completion response for
|
||||
// Claude models and places the `tool_calls` payload in a non-zero index choice.
|
||||
// Example:
|
||||
// - Choice 0: {"finish_reason":"stop","message":{"content":"I'll check the Desktop directory…"}}
|
||||
// - Choice 1: {"finish_reason":"tool_calls","message":{"tool_calls":[{"function":{"arguments":"{\"command\":
|
||||
// \"ls -1 ~/Desktop | wc -l\"}","name":"developer__shell"},…}]}}
|
||||
// This function ensures the first choice contains tool metadata so the shared formatter emits a
|
||||
// `ToolRequest` instead of returning only the plain-text choice.
|
||||
fn promote_tool_choice(response: Value) -> Value {
|
||||
@@ -644,9 +726,66 @@ fn promote_tool_choice(response: Value) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_host, promote_tool_choice, GithubCopilotUrls};
|
||||
use super::{normalize_host, promote_tool_choice, GithubCopilotProvider, GithubCopilotUrls};
|
||||
use crate::providers::utils::is_openai_responses_model;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn responses_models_routed_correctly() {
|
||||
assert!(is_openai_responses_model("gpt-5.5"));
|
||||
assert!(is_openai_responses_model("gpt-5.4"));
|
||||
assert!(is_openai_responses_model("gpt-5"));
|
||||
assert!(is_openai_responses_model("gpt-5-mini"));
|
||||
assert!(is_openai_responses_model("gpt-5-codex"));
|
||||
assert!(is_openai_responses_model("o3"));
|
||||
assert!(is_openai_responses_model("o3-mini"));
|
||||
|
||||
assert!(!is_openai_responses_model("gpt-4.1"));
|
||||
assert!(!is_openai_responses_model("gpt-4o"));
|
||||
assert!(!is_openai_responses_model("claude-sonnet-4"));
|
||||
assert!(!is_openai_responses_model("claude-haiku-4.5"));
|
||||
assert!(!is_openai_responses_model("gemini-2.5-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_images_in_messages() {
|
||||
use crate::conversation::message::Message;
|
||||
|
||||
let messages_with_image = vec![Message::user()
|
||||
.with_text("describe this")
|
||||
.with_image("base64data", "image/png")];
|
||||
assert!(GithubCopilotProvider::messages_contain_image(
|
||||
&messages_with_image
|
||||
));
|
||||
|
||||
let messages_without_image = vec![Message::user().with_text("plain text")];
|
||||
assert!(!GithubCopilotProvider::messages_contain_image(
|
||||
&messages_without_image
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_images_in_tool_responses() {
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
||||
let image_content = Content::image("aW1hZ2VkYXRh".to_string(), "image/png".to_string());
|
||||
let tool_result = Ok(CallToolResult::success(vec![image_content]));
|
||||
|
||||
let messages =
|
||||
vec![Message::user()
|
||||
.with_content(MessageContent::tool_response("call_123", tool_result))];
|
||||
assert!(GithubCopilotProvider::messages_contain_image(&messages));
|
||||
|
||||
let text_result = Ok(CallToolResult::success(vec![Content::text("no images")]));
|
||||
let messages_text_only =
|
||||
vec![Message::user()
|
||||
.with_content(MessageContent::tool_response("call_456", text_result))];
|
||||
assert!(!GithubCopilotProvider::messages_contain_image(
|
||||
&messages_text_only
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotes_choice_with_tool_call() {
|
||||
let response = json!({
|
||||
|
||||
@@ -6,28 +6,21 @@ use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse};
|
||||
use super::errors::ProviderError;
|
||||
use super::formats::openai::{create_request, get_usage, response_to_message};
|
||||
use super::formats::openai_responses::{
|
||||
create_responses_request, get_responses_usage, responses_api_to_message,
|
||||
responses_api_to_streaming_message, ResponsesApiResponse,
|
||||
create_responses_request, get_responses_usage, responses_api_to_message, ResponsesApiResponse,
|
||||
};
|
||||
use super::inventory::{config_secret_value, InventoryIdentityInput};
|
||||
use super::openai_compatible::{
|
||||
handle_response_openai_compat, handle_status, stream_openai_compat,
|
||||
handle_response_openai_compat, handle_status, stream_openai_compat, stream_responses_compat,
|
||||
};
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::ImageFormat;
|
||||
use crate::config::declarative_providers::DeclarativeProviderConfig;
|
||||
use crate::conversation::message::Message;
|
||||
use anyhow::Result;
|
||||
use async_stream::try_stream;
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use reqwest::StatusCode;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use tokio::pin;
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::base::MessageStream;
|
||||
@@ -746,20 +739,7 @@ impl Provider for OpenAiProvider {
|
||||
})?;
|
||||
|
||||
if self.supports_streaming {
|
||||
let stream = response.bytes_stream().map_err(io::Error::other);
|
||||
|
||||
Ok(Box::pin(try_stream! {
|
||||
let stream_reader = StreamReader::new(stream);
|
||||
let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from);
|
||||
|
||||
let message_stream = responses_api_to_streaming_message(framed);
|
||||
pin!(message_stream);
|
||||
while let Some(message) = message_stream.next().await {
|
||||
let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?;
|
||||
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
|
||||
yield (message, usage);
|
||||
}
|
||||
}))
|
||||
stream_responses_compat(response, log)
|
||||
} else {
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e))
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::utils::{ImageFormat, RequestLog};
|
||||
use crate::conversation::message::Message;
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::formats::openai::{create_request, response_to_streaming_message};
|
||||
use crate::providers::formats::openai_responses::responses_api_to_streaming_message;
|
||||
use rmcp::model::Tool;
|
||||
|
||||
pub struct OpenAiCompatibleProvider {
|
||||
@@ -161,6 +162,29 @@ pub fn stream_openai_compat(
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn stream_responses_compat(
|
||||
response: Response,
|
||||
mut log: RequestLog,
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||
|
||||
Ok(Box::pin(try_stream! {
|
||||
let stream_reader = StreamReader::new(stream);
|
||||
let framed = FramedRead::new(stream_reader, LinesCodec::new())
|
||||
.map_err(Error::from);
|
||||
|
||||
let message_stream = responses_api_to_streaming_message(framed);
|
||||
pin!(message_stream);
|
||||
while let Some(message) = message_stream.next().await {
|
||||
let (message, usage) = message.map_err(|e|
|
||||
ProviderError::RequestFailed(format!("Stream decode error: {e}"))
|
||||
)?;
|
||||
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
|
||||
yield (message, usage);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user