Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 89 additions & 4 deletions crates/goose/src/providers/githubcopilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = &[
"gpt-5",
"gpt-5-mini",
"gpt-5-codex",
"claude-sonnet-4",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-2.5-pro",
"grok-code-fast-1",
];
Expand Down Expand Up @@ -436,9 +433,10 @@ impl Provider for GithubCopilotProvider {
self.post(&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(|| {
Expand Down Expand Up @@ -549,3 +547,90 @@ impl Provider for GithubCopilotProvider {
Ok(())
}
}

// 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 {
let Some(choices) = response.get("choices").and_then(|c| c.as_array()) else {
return response;
};

let tool_choice_idx = choices.iter().position(|choice| {
choice
.get("message")
.and_then(|m| m.get("tool_calls"))
.and_then(|tc| tc.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
});

if let Some(idx) = tool_choice_idx {
if idx != 0 {
let mut new_response = response;
if let Some(new_choices) = new_response
.get_mut("choices")
.and_then(|c| c.as_array_mut())
{
let choice = new_choices.remove(idx);
new_choices.insert(0, choice);
}
return new_response;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me, but wouldn't it be simpler to do a Vec::sort_by?

also I wonder if this would make sense to have in formats::openai::response_to_message, since that is where we select the first choice

Copy link
Collaborator Author

@lifeizhou-ap lifeizhou-ap Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamadeo Thanks for the review!

"sort_by" could reorder all the choices. the current approach only moves the first choice with "tool_calls" to index 0 while preserving the rest.

also I wonder if this would make sense to have in formats::openai::response_to_message, since that is where we select the first choice

Initially I implemented this logic in formats::openai::response_to_message, but later on I moved to this logic to this file to avoid complexity in formats::openai as this is the special case with github_copilot provider. When the other provider has the similar use case, we can move to formats::openai. WDYT? I can create a followup PR if it makes more sense to include this logic in formats::openai


response
}

#[cfg(test)]
mod tests {
use super::promote_tool_choice;
use serde_json::json;

#[test]
fn promotes_choice_with_tool_call() {
let response = json!({
"choices": [
{"message": {"content": "plain text"}},
{"message": {"tool_calls": [{"function": {"name": "foo", "arguments": "{}"}}]}}
]
});

let promoted = promote_tool_choice(response);
assert_eq!(
promoted
.get("choices")
.and_then(|c| c.as_array())
.map(|c| c.len()),
Some(2)
);
let first_choice = promoted
.get("choices")
.and_then(|c| c.as_array())
.and_then(|c| c.first())
.unwrap();

assert!(first_choice
.get("message")
.and_then(|m| m.get("tool_calls"))
.is_some());
}

#[test]
fn leaves_response_when_tool_choice_first() {
let response = json!({
"choices": [
{"message": {"tool_calls": [{"function": {"name": "foo", "arguments": "{}"}}]}},
{"message": {"content": "plain text"}}
]
});

let promoted = promote_tool_choice(response.clone());
assert_eq!(promoted, response);
}
}
Loading