use crate::providers::common::{http_error_kind, llm_error_kind, log_identity};
use async_trait::async_trait;
use reqwest::Client;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
pub mod auth;
use crate::provider::{
LLMError, LLMProvider, LLMRequestOptions, LLMStream, ProviderModelInfo,
ProviderVisibleToolFootprint, ProviderVisibleToolSegment, ProviderVisibleToolSegmentKind,
ResponsesRequestOptions, Result,
};
use crate::types::LLMChunk;
use auth::{CopilotAuthHandler, DeviceCodeResponse};
use bamboo_config::{KeywordMaskingConfig, RequestOverridesConfig};
use bamboo_domain::Message;
use bamboo_domain::ReasoningEffort;
use bamboo_domain::ToolSchema;
use super::common::openai_compat::{
messages_to_openai_compat_json, openai_compat_chat_stream_from_sse,
parse_openai_compat_sse_data_lenient_multi, set_openai_compat_token_limit,
tools_to_openai_compat_json,
};
use super::common::openai_responses::{
build_responses_body, retain_provider_transcript_family, select_responses_input_messages,
tools_to_responses_json, ResponsesInputSource, ResponsesSseParser,
};
use super::common::request_overrides;
use super::common::responses_debug::append_responses_sse_record;
use super::common::sse::llm_stream_from_sse_multi_requiring_done;
const COPILOT_TRANSPORT_MAX_ATTEMPTS: usize = 2;
const COPILOT_TRANSPORT_RETRY_BASE_DELAY_MS: u64 = 250;
const COPILOT_MODELS_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
pub struct CopilotProvider {
client: Client,
token: Option<String>,
token_expires_at: Option<u64>,
auth_handler: Option<CopilotAuthHandler>,
vscode_session_id: String,
vscode_machine_id: String,
responses_only_models: Vec<String>,
default_reasoning_effort: Option<ReasoningEffort>,
request_overrides: Option<RequestOverridesConfig>,
masking_config: KeywordMaskingConfig,
models_cache: RwLock<Option<CopilotModelsCache>>,
#[cfg(test)]
test_model_base_url: Option<String>,
}
#[derive(Debug, Clone)]
struct CopilotModelsCache {
fetched_at: Instant,
models: Vec<ProviderModelInfo>,
}
impl CopilotProvider {
fn model_endpoint(&self, path: &str) -> String {
#[cfg(test)]
if let Some(base) = &self.test_model_base_url {
return format!("{}/{}", base.trim_end_matches('/'), path);
}
format!("https://api.githubcopilot.com/{path}")
}
pub fn new() -> Self {
Self {
client: Client::new(),
token: None,
token_expires_at: None,
auth_handler: None,
vscode_session_id: Self::generate_vscode_session_id(),
vscode_machine_id: Self::generate_vscode_machine_id(),
responses_only_models: vec![],
default_reasoning_effort: None,
request_overrides: None,
masking_config: KeywordMaskingConfig::default(),
models_cache: RwLock::new(None),
#[cfg(test)]
test_model_base_url: None,
}
}
pub fn with_token(token: impl Into<String>) -> Self {
Self {
client: Client::new(),
token: Some(token.into()),
token_expires_at: None,
auth_handler: None,
vscode_session_id: Self::generate_vscode_session_id(),
vscode_machine_id: Self::generate_vscode_machine_id(),
responses_only_models: vec![],
default_reasoning_effort: None,
request_overrides: None,
masking_config: KeywordMaskingConfig::default(),
models_cache: RwLock::new(None),
#[cfg(test)]
test_model_base_url: None,
}
}
pub fn with_auth_handler(client: Client, app_data_dir: PathBuf, headless_auth: bool) -> Self {
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use std::sync::Arc;
use std::time::Duration;
let retry_policy = ExponentialBackoff::builder()
.retry_bounds(Duration::from_millis(100), Duration::from_secs(5))
.build_with_max_retries(3);
let client_with_middleware = Arc::new(
ClientBuilder::new(client.clone())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build(),
);
let auth_handler =
CopilotAuthHandler::new(client_with_middleware, app_data_dir, headless_auth);
Self {
client,
token: None,
token_expires_at: None,
auth_handler: Some(auth_handler),
vscode_session_id: Self::generate_vscode_session_id(),
vscode_machine_id: Self::generate_vscode_machine_id(),
responses_only_models: vec![],
default_reasoning_effort: None,
request_overrides: None,
masking_config: KeywordMaskingConfig::default(),
models_cache: RwLock::new(None),
#[cfg(test)]
test_model_base_url: None,
}
}
pub fn with_responses_only_models(mut self, models: Vec<String>) -> Self {
self.responses_only_models = models;
self
}
pub fn with_masking(mut self, masking_config: KeywordMaskingConfig) -> Self {
self.masking_config = masking_config;
self
}
pub fn with_reasoning_effort(mut self, effort: Option<ReasoningEffort>) -> Self {
self.default_reasoning_effort = effort;
self
}
pub fn with_request_overrides(mut self, overrides: Option<RequestOverridesConfig>) -> Self {
self.request_overrides = overrides;
self
}
pub fn is_authenticated(&self) -> bool {
self.token.is_some()
}
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
pub async fn try_authenticate_silent(&mut self) -> std::result::Result<bool, LLMError> {
if let Some(handler) = &self.auth_handler {
match handler.try_get_chat_token_silent().await {
Ok(Some(token)) => {
self.token = Some(token);
return Ok(true);
}
Ok(None) => return Ok(false),
Err(e) => return Err(LLMError::Auth(e.to_string())),
}
}
Ok(false)
}
pub async fn authenticate(&mut self) -> std::result::Result<(), LLMError> {
if self.try_authenticate_silent().await? {
return Ok(());
}
if let Some(handler) = &self.auth_handler {
let token = handler
.get_chat_token()
.await
.map_err(|e| LLMError::Auth(e.to_string()))?;
self.token = Some(token);
Ok(())
} else {
Err(LLMError::Auth("No auth handler configured".to_string()))
}
}
pub async fn start_authentication(&self) -> std::result::Result<DeviceCodeResponse, LLMError> {
if let Some(handler) = &self.auth_handler {
handler
.start_authentication()
.await
.map_err(|e| LLMError::Auth(e.to_string()))
} else {
Err(LLMError::Auth("No auth handler configured".to_string()))
}
}
pub async fn complete_authentication(
&mut self,
device_code: &DeviceCodeResponse,
) -> std::result::Result<(), LLMError> {
if let Some(handler) = &self.auth_handler {
let config = handler
.complete_authentication(device_code)
.await
.map_err(|e| LLMError::Auth(e.to_string()))?;
self.token = Some(config.token);
self.token_expires_at = Some(config.expires_at);
Ok(())
} else {
Err(LLMError::Auth("No auth handler configured".to_string()))
}
}
pub async fn logout(&mut self) -> std::result::Result<(), LLMError> {
if let Some(handler) = &self.auth_handler {
handler
.clear_cached_credentials()
.map_err(|_| LLMError::Auth("Failed to clear Copilot credentials".to_string()))?;
}
self.token = None;
self.token_expires_at = None;
tracing::info!("Logged out and deleted cached tokens");
Ok(())
}
fn build_headers_with_token(
token: &str,
) -> std::result::Result<reqwest::header::HeaderMap, LLMError> {
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token))
.map_err(|e| LLMError::Auth(format!("Invalid token: {}", e)))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert("editor-version", HeaderValue::from_static("vscode/1.99.2"));
headers.insert(
"editor-plugin-version",
HeaderValue::from_static("copilot-chat/0.20.3"),
);
headers.insert(
"user-agent",
HeaderValue::from_static("GitHubCopilotChat/0.20.3"),
);
headers.insert("accept", HeaderValue::from_static("application/json"));
headers.insert(
"accept-encoding",
HeaderValue::from_static("gzip, deflate, br"),
);
headers.insert(
"copilot-integration-id",
HeaderValue::from_static("vscode-chat"),
);
Ok(headers)
}
fn generate_vscode_session_id() -> String {
format!(
"{}{}",
uuid::Uuid::new_v4(),
chrono::Utc::now().timestamp_millis()
)
}
fn generate_vscode_machine_id() -> String {
uuid::Uuid::new_v4()
.simple()
.to_string()
.repeat(2)
.chars()
.take(64)
.collect()
}
fn infer_request_initiator(messages: &[Message]) -> &'static str {
messages
.iter()
.rev()
.find(|message| !matches!(message.role, bamboo_domain::Role::System))
.map(|message| match message.role {
bamboo_domain::Role::User => "user",
bamboo_domain::Role::Assistant | bamboo_domain::Role::Tool => "agent",
bamboo_domain::Role::System => "user",
})
.unwrap_or("user")
}
fn infer_openai_intent(messages: &[Message], tools: &[ToolSchema]) -> &'static str {
let has_agent_activity = messages.iter().any(|message| {
matches!(
message.role,
bamboo_domain::Role::Assistant | bamboo_domain::Role::Tool
) || message
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty())
});
if !tools.is_empty() || has_agent_activity {
"conversation-agent"
} else {
"conversation-panel"
}
}
fn build_llm_headers(
&self,
token: &str,
messages: &[Message],
tools: &[ToolSchema],
endpoint: &str,
model: Option<&str>,
) -> std::result::Result<reqwest::header::HeaderMap, LLMError> {
use reqwest::header::HeaderValue;
let mut headers = Self::build_headers_with_token(token)?;
headers.insert(
"openai-organization",
HeaderValue::from_static("github-copilot"),
);
headers.insert(
"openai-intent",
HeaderValue::from_static(Self::infer_openai_intent(messages, tools)),
);
headers.insert(
"x-github-api-version",
HeaderValue::from_static("2025-05-01"),
);
headers.insert(
"x-initiator",
HeaderValue::from_static(Self::infer_request_initiator(messages)),
);
headers.insert(
"x-interaction-id",
HeaderValue::from_str(&self.vscode_session_id)
.map_err(|e| LLMError::Auth(format!("Invalid x-interaction-id: {}", e)))?,
);
headers.insert(
"vscode-sessionid",
HeaderValue::from_str(&self.vscode_session_id)
.map_err(|e| LLMError::Auth(format!("Invalid vscode-sessionid: {}", e)))?,
);
headers.insert(
"vscode-machineid",
HeaderValue::from_str(&self.vscode_machine_id)
.map_err(|e| LLMError::Auth(format!("Invalid vscode-machineid: {}", e)))?,
);
headers.insert(
"x-request-id",
HeaderValue::from_str(&uuid::Uuid::new_v4().to_string())
.map_err(|e| LLMError::Auth(format!("Invalid x-request-id: {}", e)))?,
);
request_overrides::apply_overrides_to_header_map(
&mut headers,
self.request_overrides.as_ref(),
endpoint,
model,
);
Ok(headers)
}
#[allow(dead_code)] fn build_headers(&self) -> std::result::Result<reqwest::header::HeaderMap, LLMError> {
let token = self
.token
.as_ref()
.ok_or_else(|| LLMError::Auth("Not authenticated".to_string()))?;
self.build_llm_headers(
token,
&[],
&[],
request_overrides::ENDPOINT_CHAT_COMPLETIONS,
None,
)
}
async fn get_token_for_request(&self) -> std::result::Result<String, LLMError> {
if let Some(handler) = &self.auth_handler {
match handler.try_get_chat_token_silent().await {
Ok(Some(token)) => return Ok(token),
Ok(None) => { }
Err(e) => return Err(LLMError::Auth(e.to_string())),
}
}
if let Some(token) = self.token.as_ref() {
return Ok(token.clone());
}
Err(LLMError::Auth(
"Not authenticated. Please run authenticate() first.".to_string(),
))
}
}
impl Default for CopilotProvider {
fn default() -> Self {
Self::new()
}
}
impl CopilotProvider {
fn matches_model_pattern(pattern: &str, model: &str) -> bool {
let p = pattern.trim().to_ascii_lowercase();
if p.is_empty() {
return false;
}
let m = model.trim().to_ascii_lowercase();
if let Some(prefix) = p.strip_suffix('*') {
return m.starts_with(prefix);
}
m == p
}
fn uses_responses_api(&self, model: &str) -> bool {
self.responses_only_models
.iter()
.any(|p| Self::matches_model_pattern(p, model))
}
fn looks_like_responses_only_error(status: reqwest::StatusCode, body: &str) -> bool {
if !(status == 400
|| status == 404
|| status == 405
|| status == 409
|| status == 415
|| status == 422)
{
return false;
}
let b = body.to_ascii_lowercase();
b.contains("/responses") || b.contains("responses api") || b.contains("use responses")
}
fn looks_like_previous_response_id_unsupported_error(
status: reqwest::StatusCode,
body: &str,
) -> bool {
if !(status == 400 || status == 409 || status == 422) {
return false;
}
let b = body.to_ascii_lowercase();
let mentions_previous_response_id = b.contains("previous_response_id");
let mentions_unsupported = b.contains("unsupported_value")
|| b.contains("not supported")
|| b.contains("unsupported");
mentions_previous_response_id && mentions_unsupported
}
fn is_retryable_transport_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
let retryable_markers = [
"incompletemessage",
"error sending request",
"connection reset",
"broken pipe",
"connection aborted",
"connection closed",
"unexpected eof",
"timed out",
"timeout",
];
retryable_markers
.iter()
.any(|marker| lower.contains(marker))
}
fn is_retryable_transport_error(error: &reqwest::Error) -> bool {
error.is_timeout()
|| error.is_connect()
|| error.is_request()
|| Self::is_retryable_transport_message(&error.to_string())
}
async fn send_model_request<F>(
&self,
build_request: F,
_operation: &str,
_session_id: Option<&str>,
) -> std::result::Result<reqwest::Response, LLMError>
where
F: Fn() -> reqwest::RequestBuilder,
{
crate::retry::send_provider_request(crate::retry::global(), "Copilot", build_request).await
}
async fn send_with_transport_retry<F>(
&self,
mut build_request: F,
operation: &str,
session_id: Option<&str>,
) -> std::result::Result<reqwest::Response, LLMError>
where
F: FnMut() -> reqwest::RequestBuilder,
{
let session_log_id = session_id.unwrap_or("unknown-session");
for attempt in 1..=COPILOT_TRANSPORT_MAX_ATTEMPTS {
crate::retry::claim_work_physical_request()?;
match build_request().send().await {
Ok(response) => return Ok(response),
Err(error) => {
if attempt >= COPILOT_TRANSPORT_MAX_ATTEMPTS
|| !Self::is_retryable_transport_error(&error)
{
return Err(LLMError::Http(error));
}
let delay_ms = COPILOT_TRANSPORT_RETRY_BASE_DELAY_MS * (1u64 << (attempt - 1));
tracing::warn!(
"[session_hash={}] Copilot transport error during {} (attempt {}/{}): error_kind={}. Retrying in {}ms",
log_identity(session_log_id),
operation,
attempt,
COPILOT_TRANSPORT_MAX_ATTEMPTS,
http_error_kind(&error),
delay_ms
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
unreachable!("loop always returns or errors")
}
#[allow(clippy::too_many_arguments)]
async fn chat_stream_via_responses(
&self,
token: &str,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
reasoning_effort: Option<ReasoningEffort>,
responses_options: Option<&ResponsesRequestOptions>,
parallel_tool_calls: Option<bool>,
required_tool: Option<&str>,
reasoning_source: &str,
session_log_id: &str,
request_purpose: &str,
) -> Result<LLMStream> {
let url = self.model_endpoint("responses");
let mut effective_responses_options = responses_options.cloned().unwrap_or_default();
if effective_responses_options.store == Some(true) {
tracing::warn!(
"[session_hash={}] Copilot /responses does not support store=true; forcing store=false",
log_identity(session_log_id)
);
}
effective_responses_options.store = Some(false);
let retain_protocol_events = effective_responses_options.retain_protocol_events;
effective_responses_options.prompt_cache_key = None;
effective_responses_options.prompt_cache_options = None;
effective_responses_options.raw_input_with_cache_breakpoints = None;
retain_provider_transcript_family(
&mut effective_responses_options,
bamboo_domain::ProviderFamily::Copilot,
);
let input_selection =
select_responses_input_messages(messages, Some(&effective_responses_options));
let input_source = match input_selection.source {
ResponsesInputSource::Explicit => "explicit",
ResponsesInputSource::Generic => "generic",
};
let mut body = build_responses_body(
model,
messages,
tools,
max_output_tokens,
reasoning_effort,
Some(&effective_responses_options),
parallel_tool_calls,
None,
);
request_overrides::validate_budgeted_overrides(
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_RESPONSES,
Some(model),
)?;
request_overrides::apply_overrides_to_body(
&mut body,
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_RESPONSES,
Some(model),
);
if let Some(name) = required_tool {
body["tool_choice"] = json!({"type": "function", "name": name});
}
crate::masking::mask_outbound_body(&mut body, &self.masking_config);
tracing::debug!(
"[session_hash={}] Copilot provider using Responses API model_hash={}",
log_identity(session_log_id),
log_identity(model)
);
tracing::info!(
"[session_hash={}] Copilot request protocol=responses model_hash={} reasoning_effort={} reasoning_source={} request_reasoning_enabled={} max_output_tokens={} input_source={} input_messages_before={} input_messages_after={} duplicate_system_fallback={} purpose_hash={}",
log_identity(session_log_id),
log_identity(model),
reasoning_effort
.map(ReasoningEffort::as_str)
.unwrap_or("none"),
reasoning_source,
reasoning_effort.is_some(),
max_output_tokens
.map(|tokens| tokens.to_string())
.unwrap_or_else(|| "none".to_string()),
input_source,
input_selection.original_len,
input_selection.effective_len,
input_selection.fallback_removed_duplicate_system,
log_identity(request_purpose)
);
let request_headers = self.build_llm_headers(
token,
messages,
tools,
request_overrides::ENDPOINT_RESPONSES,
Some(model),
)?;
let mut response = self
.send_model_request(
|| {
self.client
.post(&url)
.headers(request_headers.clone())
.json(&body)
},
"copilot responses initial request",
Some(session_log_id),
)
.await?;
if !response.status().is_success() {
let status = response.status();
if (status == 401 || status == 403) && self.auth_handler.is_some() {
if let Some(handler) = &self.auth_handler {
if let Ok(Some(refreshed)) = handler.force_refresh_chat_token().await {
let refreshed_headers = self.build_llm_headers(
&refreshed,
messages,
tools,
request_overrides::ENDPOINT_RESPONSES,
Some(model),
)?;
response = self
.send_model_request(
|| {
self.client
.post(&url)
.headers(refreshed_headers.clone())
.json(&body)
},
"copilot responses auth-refresh retry",
Some(session_log_id),
)
.await?;
}
}
}
if !response.status().is_success() {
let status = response.status();
let request_id = response
.headers()
.get("x-request-id")
.or_else(|| response.headers().get("x-github-request-id"))
.and_then(|v| v.to_str().ok())
.unwrap_or("-")
.to_string();
let response_header_count = response.headers().len();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(LLMError::Auth(format!(
"Authentication failed: {}. Please run authenticate() again.",
text
)));
}
if effective_responses_options.previous_response_id.is_some()
&& Self::looks_like_previous_response_id_unsupported_error(status, &text)
{
return Err(LLMError::Api(format!(
"HTTP {} (request_id={}): Copilot HTTP /responses does not support previous_response_id; same-request tool continuation requires the websocket transport used by VS Code Copilot Chat. Upstream response: {}",
status, request_id, text
)));
}
let request_body_bytes = serde_json::to_vec(&body).map(|v| v.len()).unwrap_or(0);
tracing::error!(
"[session_hash={}] Copilot Responses API error: HTTP {} response_bytes={} (request_id_hash={}, model_hash={}, input_source={}, input_messages_before={}, input_messages_after={}, duplicate_system_fallback={}, tools={}, request_body_bytes={}, max_output_tokens={:?}, reasoning_effort={:?})",
log_identity(session_log_id),
status,
text.len(),
log_identity(&request_id),
log_identity(model),
input_source,
input_selection.original_len,
input_selection.effective_len,
input_selection.fallback_removed_duplicate_system,
tools.len(),
request_body_bytes,
max_output_tokens,
reasoning_effort
);
tracing::debug!(
"[session_hash={}] Copilot Responses API error response_header_count={}",
log_identity(session_log_id),
response_header_count
);
return Err(LLMError::Api(format!(
"HTTP {} (request_id={}): {}",
status, request_id, text
)));
}
}
let mut parser = ResponsesSseParser::new_with_context("Copilot", model, reasoning_effort)
.with_protocol_events(retain_protocol_events);
let model_for_debug = model.to_string();
let stream = llm_stream_from_sse_multi_requiring_done(
response,
move |event, data| {
let parsed = parser.handle_event_multi(event, data);
append_responses_sse_record("Copilot", &model_for_debug, event, data, &parsed);
parsed
},
"Copilot Responses",
);
Ok(stream)
}
fn parse_token_limit(value: &serde_json::Value) -> Option<u32> {
match value {
serde_json::Value::Number(number) => number
.as_u64()
.and_then(|raw| u32::try_from(raw).ok())
.or_else(|| {
number.as_f64().and_then(|raw| {
if raw.is_finite() && raw >= 0.0 {
let floor = raw.floor();
if floor <= u32::MAX as f64 {
Some(floor as u32)
} else {
None
}
} else {
None
}
})
}),
serde_json::Value::String(raw) => raw.trim().parse::<u32>().ok(),
_ => None,
}
}
fn find_max_token_limit_by_keys(value: &serde_json::Value, keys: &[&str]) -> Option<u32> {
match value {
serde_json::Value::Object(object) => {
object.iter().fold(None, |current_max, (key, child)| {
let direct = if keys.iter().any(|candidate| *candidate == key) {
Self::parse_token_limit(child)
} else {
None
};
let nested = Self::find_max_token_limit_by_keys(child, keys);
[current_max, direct, nested].into_iter().flatten().max()
})
}
serde_json::Value::Array(items) => items.iter().fold(None, |current_max, item| {
[current_max, Self::find_max_token_limit_by_keys(item, keys)]
.into_iter()
.flatten()
.max()
}),
_ => None,
}
}
fn parse_model_info(payload: serde_json::Value) -> Result<Vec<ProviderModelInfo>> {
const TOTAL_CONTEXT_KEYS: &[&str] = &[
"max_context_tokens",
"context_window",
"context_window_tokens",
"context_length",
];
const INPUT_KEYS: &[&str] = &[
"max_input_tokens",
"max_prompt_tokens",
"input_token_limit",
"prompt_token_limit",
];
const OUTPUT_KEYS: &[&str] = &[
"max_output_tokens",
"max_completion_tokens",
"output_token_limit",
"completion_token_limit",
];
let data = payload
.get("data")
.and_then(|value| value.as_array())
.ok_or_else(|| {
LLMError::Api("Unexpected Copilot /models response format".to_string())
})?;
let mut dedup: BTreeMap<String, ProviderModelInfo> = BTreeMap::new();
for model in data {
let Some(id) = model
.get("id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let output_tokens = Self::find_max_token_limit_by_keys(model, OUTPUT_KEYS);
let context_tokens = Self::find_max_token_limit_by_keys(model, TOTAL_CONTEXT_KEYS)
.or_else(|| {
Self::find_max_token_limit_by_keys(model, INPUT_KEYS).map(|input_tokens| {
input_tokens.saturating_add(output_tokens.unwrap_or_default())
})
});
dedup
.entry(id.to_string())
.and_modify(|existing| {
existing.max_context_tokens = [existing.max_context_tokens, context_tokens]
.into_iter()
.flatten()
.max();
existing.max_output_tokens = [existing.max_output_tokens, output_tokens]
.into_iter()
.flatten()
.max();
})
.or_insert(ProviderModelInfo {
id: id.to_string(),
max_context_tokens: context_tokens,
max_output_tokens: output_tokens,
});
}
Ok(dedup.into_values().collect())
}
async fn fetch_model_info_from_api(&self) -> Result<Vec<ProviderModelInfo>> {
let token = self.get_token_for_request().await?;
let url = "https://api.githubcopilot.com/models";
let mut request_headers = Self::build_headers_with_token(&token)?;
request_overrides::apply_overrides_to_header_map(
&mut request_headers,
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_MODELS,
None,
);
let mut response = self
.send_with_transport_retry(
|| self.client.get(url).headers(request_headers.clone()),
"copilot models list",
None,
)
.await?;
if !response.status().is_success() {
let status = response.status();
if (status == 401 || status == 403) && self.auth_handler.is_some() {
if let Some(handler) = &self.auth_handler {
if let Ok(Some(refreshed)) = handler.force_refresh_chat_token().await {
let mut refreshed_headers = Self::build_headers_with_token(&refreshed)?;
request_overrides::apply_overrides_to_header_map(
&mut refreshed_headers,
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_MODELS,
None,
);
response = self
.send_with_transport_retry(
|| self.client.get(url).headers(refreshed_headers.clone()),
"copilot models list auth-refresh retry",
None,
)
.await?;
}
}
}
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(LLMError::Auth(format!(
"Authentication failed: {}. Please run authenticate() again.",
text
)));
}
tracing::error!(
status = status.as_u16(),
response_bytes = text.len(),
"Copilot models API error"
);
return Err(LLMError::Api(format!("HTTP {}: {}", status, text)));
}
}
let payload: serde_json::Value = response.json().await.map_err(LLMError::Http)?;
Self::parse_model_info(payload)
}
async fn list_model_info_cached(&self) -> Result<Vec<ProviderModelInfo>> {
let cached = self.models_cache.read().await.clone();
if let Some(cache) = &cached {
if cache.fetched_at.elapsed() <= COPILOT_MODELS_CACHE_TTL {
return Ok(cache.models.clone());
}
}
match self.fetch_model_info_from_api().await {
Ok(models) => {
let mut write_guard = self.models_cache.write().await;
*write_guard = Some(CopilotModelsCache {
fetched_at: Instant::now(),
models: models.clone(),
});
Ok(models)
}
Err(error) => {
if let Some(cache) = cached {
tracing::warn!(
"Failed to refresh Copilot model metadata; using stale cache: error_kind={}",
llm_error_kind(&error)
);
return Ok(cache.models);
}
Err(error)
}
}
}
}
#[async_trait]
impl LLMProvider for CopilotProvider {
async fn provider_visible_tool_footprint(
&self,
_ir: &crate::prompt_ir::PromptIR,
tools: &[ToolSchema],
model: &str,
_required_tool: Option<&str>,
) -> Result<ProviderVisibleToolFootprint> {
if tools.is_empty() {
return Ok(ProviderVisibleToolFootprint::default());
}
let projected = if self.uses_responses_api(model) {
tools_to_responses_json(tools)
} else {
tools_to_openai_compat_json(tools)
};
Ok(ProviderVisibleToolFootprint {
segments: vec![ProviderVisibleToolSegment::from_serializable(
ProviderVisibleToolSegmentKind::InitialFullDefinition,
&projected,
)?],
})
}
async fn chat_stream(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
) -> Result<LLMStream> {
self.chat_stream_with_options(messages, tools, max_output_tokens, model, None)
.await
}
async fn chat_stream_with_options(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
options: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
let session_log_id = options
.and_then(|value| value.session_id.as_deref())
.unwrap_or("unknown-session");
let required_tool = crate::provider::required_tool_from_options(options, tools)?;
let token = self.get_token_for_request().await?;
let reasoning_effort = options
.and_then(|o| o.reasoning_effort)
.or(self.default_reasoning_effort);
let request_reasoning_effort = options.and_then(|o| o.reasoning_effort);
let parallel_tool_calls = options.and_then(|o| o.parallel_tool_calls);
let responses_options = options.and_then(|o| o.responses.as_ref());
let request_purpose = options
.and_then(|o| o.request_purpose.as_deref())
.unwrap_or("unknown");
let reasoning_source = if request_reasoning_effort.is_some() {
"request"
} else if self.default_reasoning_effort.is_some() {
"provider_default"
} else {
"none"
};
let upstream_model = model.trim();
if upstream_model.is_empty() {
return Err(LLMError::Api(
"model is required for Copilot requests (no default model fallback)".to_string(),
));
}
tracing::debug!(
"[session_hash={}] Copilot provider using upstream model_hash={}",
log_identity(session_log_id),
log_identity(upstream_model)
);
if self.uses_responses_api(upstream_model) {
return self
.chat_stream_via_responses(
&token,
messages,
tools,
max_output_tokens,
upstream_model,
reasoning_effort,
responses_options,
parallel_tool_calls,
required_tool,
reasoning_source,
session_log_id,
request_purpose,
)
.await;
}
let mut body = json!({
"model": upstream_model,
"messages": messages_to_openai_compat_json(messages),
"stream": true,
});
if !tools.is_empty() {
body["tools"] = json!(tools_to_openai_compat_json(tools));
body["tool_choice"] = required_tool.map_or_else(
|| json!("auto"),
|name| json!({"type": "function", "function": {"name": name}}),
);
}
if let Some(parallel_tool_calls) = parallel_tool_calls {
body["parallel_tool_calls"] = json!(parallel_tool_calls);
}
set_openai_compat_token_limit(&mut body, max_output_tokens, reasoning_effort.is_some());
if let Some(reasoning_effort) = reasoning_effort {
body["reasoning_effort"] = json!(reasoning_effort.as_str());
}
request_overrides::validate_budgeted_overrides(
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_CHAT_COMPLETIONS,
Some(upstream_model),
)?;
request_overrides::apply_overrides_to_body(
&mut body,
self.request_overrides.as_ref(),
request_overrides::ENDPOINT_CHAT_COMPLETIONS,
Some(upstream_model),
);
if let Some(name) = required_tool {
body["tool_choice"] = json!({"type": "function", "function": {"name": name}});
}
crate::masking::mask_outbound_body(&mut body, &self.masking_config);
tracing::info!(
"[session_hash={}] Copilot request protocol=chat_completions model_hash={} reasoning_effort={} reasoning_source={} request_reasoning_enabled={} max_output_tokens={} purpose_hash={}",
log_identity(session_log_id),
log_identity(upstream_model),
reasoning_effort
.map(ReasoningEffort::as_str)
.unwrap_or("none"),
reasoning_source,
reasoning_effort.is_some(),
max_output_tokens
.map(|tokens| tokens.to_string())
.unwrap_or_else(|| "none".to_string()),
log_identity(request_purpose)
);
tracing::debug!(
"[session_hash={}] Sending request to Copilot API with {} messages and {} tools",
log_identity(session_log_id),
messages.len(),
tools.len()
);
let url = self.model_endpoint("chat/completions");
let request_headers = self.build_llm_headers(
&token,
messages,
tools,
request_overrides::ENDPOINT_CHAT_COMPLETIONS,
Some(upstream_model),
)?;
let mut response = self
.send_model_request(
|| {
self.client
.post(&url)
.headers(request_headers.clone())
.json(&body)
},
"copilot chat/completions initial request",
Some(session_log_id),
)
.await?;
if !response.status().is_success() {
let status = response.status();
if (status == 401 || status == 403) && self.auth_handler.is_some() {
if let Some(handler) = &self.auth_handler {
if let Ok(Some(refreshed)) = handler.force_refresh_chat_token().await {
let refreshed_headers = self.build_llm_headers(
&refreshed,
messages,
tools,
request_overrides::ENDPOINT_CHAT_COMPLETIONS,
Some(upstream_model),
)?;
response = self
.send_model_request(
|| {
self.client
.post(&url)
.headers(refreshed_headers.clone())
.json(&body)
},
"copilot chat/completions auth-refresh retry",
Some(session_log_id),
)
.await?;
}
}
}
if !response.status().is_success() {
let status = response.status();
let request_id = response
.headers()
.get("x-request-id")
.or_else(|| response.headers().get("x-github-request-id"))
.and_then(|v| v.to_str().ok())
.unwrap_or("-")
.to_string();
let response_header_count = response.headers().len();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(LLMError::Auth(format!(
"Authentication failed: {}. Please run authenticate() again.",
text
)));
}
if Self::looks_like_responses_only_error(status, &text) {
tracing::info!(
"[session_hash={}] Copilot chat/completions rejected model_hash {}; retrying via /responses",
log_identity(session_log_id),
log_identity(upstream_model)
);
return self
.chat_stream_via_responses(
&token,
messages,
tools,
max_output_tokens,
upstream_model,
reasoning_effort,
responses_options,
parallel_tool_calls,
required_tool,
reasoning_source,
session_log_id,
request_purpose,
)
.await;
}
let request_body_bytes = serde_json::to_vec(&body).map(|v| v.len()).unwrap_or(0);
tracing::error!(
"[session_hash={}] Copilot API error: HTTP {} response_bytes={} (request_id_hash={}, model_hash={}, messages={}, tools={}, request_body_bytes={}, max_output_tokens={:?}, reasoning_effort={:?})",
log_identity(session_log_id),
status,
text.len(),
log_identity(&request_id),
log_identity(upstream_model),
messages.len(),
tools.len(),
request_body_bytes,
max_output_tokens,
reasoning_effort
);
tracing::debug!(
"[session_hash={}] Copilot API error response_header_count={}",
log_identity(session_log_id),
response_header_count
);
return Err(LLMError::Api(format!(
"HTTP {} (request_id={}): {}",
status, request_id, text
)));
}
}
let model_for_log = upstream_model.to_string();
let requested_reasoning = reasoning_effort;
let session_for_log = session_log_id.to_string();
let mut observed_reasoning_signal = false;
let mut reasoning_chars = 0usize;
let mut logged_summary = false;
let stream = openai_compat_chat_stream_from_sse(response, move |_event, data| {
let mut reasoning_chunk_to_emit: Option<String> = None;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(delta) = v
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("delta"))
{
let has_answer_content = delta
.get("content")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.is_empty());
let reasoning_chunk = delta
.get("reasoning_content")
.and_then(|value| value.as_str())
.or_else(|| delta.get("reasoning").and_then(|value| value.as_str()));
if let Some(reasoning_chunk) = reasoning_chunk {
observed_reasoning_signal = true;
reasoning_chars = reasoning_chars.saturating_add(reasoning_chunk.len());
if !reasoning_chunk.is_empty() && !has_answer_content {
reasoning_chunk_to_emit = Some(reasoning_chunk.to_string());
}
}
}
}
let mut chunks = parse_openai_compat_sse_data_lenient_multi(data)?;
if let Some(reasoning_chunk) = reasoning_chunk_to_emit {
chunks.retain(|chunk| !matches!(chunk, LLMChunk::Token(token) if token.is_empty()));
chunks.insert(0, LLMChunk::ReasoningToken(reasoning_chunk));
}
if chunks.iter().any(|chunk| matches!(chunk, LLMChunk::Done))
&& !logged_summary
&& (requested_reasoning.is_some() || observed_reasoning_signal)
{
tracing::info!(
"[session_hash={}] Copilot chat_completions reasoning summary: model_hash={} requested_effort={} observed_reasoning_signal={} reasoning_text_chars={}",
log_identity(&session_for_log),
log_identity(&model_for_log),
requested_reasoning
.map(ReasoningEffort::as_str)
.unwrap_or("none"),
observed_reasoning_signal,
reasoning_chars
);
logged_summary = true;
}
Ok(chunks)
});
Ok(stream)
}
async fn list_models(&self) -> Result<Vec<String>> {
Ok(self
.list_model_info_cached()
.await?
.into_iter()
.map(|model| model.id)
.collect())
}
async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
self.list_model_info_cached().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_domain::FunctionSchema;
use futures::StreamExt;
fn should_skip() -> bool {
std::env::var_os("CODEX_SANDBOX").is_some()
}
#[test]
fn test_new_provider() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
assert!(!provider.is_authenticated());
}
#[test]
fn model_info_adds_output_capacity_to_input_only_limit() {
let models = CopilotProvider::parse_model_info(serde_json::json!({
"data": [{
"id": "dynamic-model",
"capabilities": {
"limits": {
"max_input_tokens": 200_000,
"max_output_tokens": 64_000
}
}
}]
}))
.expect("parse model metadata");
assert_eq!(models.len(), 1);
assert_eq!(models[0].max_context_tokens, Some(264_000));
assert_eq!(models[0].max_output_tokens, Some(64_000));
}
#[test]
fn model_info_does_not_add_output_to_explicit_total_context_window() {
let models = CopilotProvider::parse_model_info(serde_json::json!({
"data": [{
"id": "dynamic-model",
"context_window": 200_000,
"max_output_tokens": 64_000
}]
}))
.expect("parse model metadata");
assert_eq!(models.len(), 1);
assert_eq!(models[0].max_context_tokens, Some(200_000));
assert_eq!(models[0].max_output_tokens, Some(64_000));
}
#[tokio::test]
async fn required_tool_missing_schema_fails_before_auth_resolution() {
let provider = CopilotProvider::new();
let options = LLMRequestOptions {
required_tool: Some("load_skill".to_string()),
..Default::default()
};
let result = provider
.chat_stream_with_options(
&[Message::user("activate")],
&[],
Some(128),
"gpt-5",
Some(&options),
)
.await;
let error = match result {
Ok(_) => panic!("missing required tool schema must fail before auth or network"),
Err(error) => error,
};
assert!(error
.to_string()
.contains("required tool schema 'load_skill' was not offered"));
}
#[tokio::test]
async fn chat_sse_production_adapter_keeps_usage_before_one_terminal_done() {
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body(
concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"answer\"},\"finish_reason\":null}]}\n\n",
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":4}}\n\n",
"data: [DONE]\n\n",
)
.to_string(),
)
.expect("http response"),
);
let mut stream = openai_compat_chat_stream_from_sse(response, |_event, data| {
parse_openai_compat_sse_data_lenient_multi(data)
});
let mut chunks = Vec::new();
while let Some(item) = stream.next().await {
chunks.push(item.expect("Copilot chat stream chunk"));
}
assert_eq!(chunks.len(), 3);
assert!(matches!(&chunks[0], LLMChunk::Token(token) if token == "answer"));
assert!(matches!(chunks[1], LLMChunk::ProviderUsage { .. }));
assert!(matches!(chunks[2], LLMChunk::Done));
assert_eq!(
chunks
.iter()
.filter(|chunk| matches!(chunk, LLMChunk::Done))
.count(),
1
);
}
#[test]
fn test_with_token() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
assert!(provider.is_authenticated());
assert_eq!(provider.token(), Some("test_token"));
}
#[test]
fn responses_only_models_matches_exact_and_prefix() {
let provider = CopilotProvider::new()
.with_responses_only_models(vec!["gpt-5.3-codex".to_string(), "gpt-5*".to_string()]);
assert!(provider.uses_responses_api("gpt-5.3-codex"));
assert!(provider.uses_responses_api("gpt-5.4-whatever"));
assert!(!provider.uses_responses_api("gpt-4o"));
}
#[tokio::test]
async fn tool_footprint_matches_copilot_chat_and_responses_routes() {
let tools = vec![ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: "lookup".to_string(),
description: "Lookup".to_string(),
parameters: json!({"type":"object"}),
},
}];
let provider =
CopilotProvider::new().with_responses_only_models(vec!["gpt-5*".to_string()]);
let chat = provider
.provider_visible_tool_footprint(
&crate::prompt_ir::PromptIR::default(),
&tools,
"gpt-4o",
None,
)
.await
.unwrap();
assert_eq!(
chat.segments[0].serialized,
serde_json::to_string(&tools_to_openai_compat_json(&tools)).unwrap()
);
let responses = provider
.provider_visible_tool_footprint(
&crate::prompt_ir::PromptIR::default(),
&tools,
"gpt-5.6",
None,
)
.await
.unwrap();
assert_eq!(
responses.segments[0].serialized,
serde_json::to_string(&tools_to_responses_json(&tools)).unwrap()
);
assert_ne!(
responses.segments[0].serialized,
chat.segments[0].serialized
);
}
#[test]
fn test_default_values() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
assert!(provider.token.is_none());
assert!(provider.token_expires_at.is_none());
assert!(!provider.is_authenticated());
}
#[test]
fn transport_retry_message_detection_handles_incomplete_message() {
assert!(CopilotProvider::is_retryable_transport_message(
"error sending request for url: hyper::Error(IncompleteMessage)",
));
}
#[test]
fn transport_retry_message_detection_ignores_validation_errors() {
assert!(!CopilotProvider::is_retryable_transport_message(
"HTTP 400: invalid request body",
));
}
#[test]
fn test_with_token_chaining() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("my_token_123");
assert!(provider.is_authenticated());
assert_eq!(provider.token(), Some("my_token_123"));
assert!(provider.token.is_some());
}
#[test]
fn test_token_expiry() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
assert!(provider.token_expires_at.is_none());
let new_provider = CopilotProvider::new();
assert!(new_provider.token_expires_at.is_none());
}
#[test]
fn test_build_headers_success() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
let headers = provider.build_headers().unwrap();
assert!(headers.contains_key("authorization"));
let auth_header = headers.get("authorization").unwrap();
assert_eq!(auth_header, "Bearer test_token");
}
#[test]
fn test_build_headers_without_token() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
let result = provider.build_headers();
assert!(result.is_err());
match result {
Err(LLMError::Auth(msg)) => {
assert!(msg.contains("Not authenticated"));
}
_ => panic!("Expected Auth error"),
}
}
#[test]
fn test_headers_contain_required_fields() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
let headers = provider.build_headers().unwrap();
assert!(headers.contains_key("authorization"));
assert!(headers.contains_key("content-type"));
assert!(headers.contains_key("editor-version"));
assert!(headers.contains_key("editor-plugin-version"));
assert!(headers.contains_key("user-agent"));
assert!(headers.contains_key("accept"));
assert!(headers.contains_key("accept-encoding"));
assert!(headers.contains_key("copilot-integration-id"));
assert!(headers.contains_key("openai-organization"));
assert!(headers.contains_key("openai-intent"));
assert!(headers.contains_key("x-github-api-version"));
assert!(headers.contains_key("x-request-id"));
assert!(headers.contains_key("x-initiator"));
assert!(headers.contains_key("x-interaction-id"));
assert!(headers.contains_key("vscode-sessionid"));
assert!(headers.contains_key("vscode-machineid"));
assert_eq!(headers.get("editor-version").unwrap(), "vscode/1.99.2");
assert_eq!(
headers.get("editor-plugin-version").unwrap(),
"copilot-chat/0.20.3"
);
assert_eq!(
headers.get("user-agent").unwrap(),
"GitHubCopilotChat/0.20.3"
);
assert_eq!(
headers.get("copilot-integration-id").unwrap(),
"vscode-chat"
);
assert_eq!(headers.get("content-type").unwrap(), "application/json");
assert_eq!(
headers.get("openai-organization").unwrap(),
"github-copilot"
);
assert_eq!(headers.get("openai-intent").unwrap(), "conversation-panel");
assert_eq!(headers.get("x-github-api-version").unwrap(), "2025-05-01");
assert_eq!(headers.get("x-initiator").unwrap(), "user");
assert!(
uuid::Uuid::parse_str(headers.get("x-request-id").unwrap().to_str().unwrap()).is_ok()
);
}
#[test]
fn infer_openai_intent_uses_agent_for_tool_loops() {
let messages = vec![
Message::user("run a tool"),
Message::assistant("calling tool", None),
Message::tool_result("call_1", "{\"ok\":true}"),
];
assert_eq!(
CopilotProvider::infer_openai_intent(&messages, &[]),
"conversation-agent"
);
assert_eq!(CopilotProvider::infer_request_initiator(&messages), "agent");
}
#[test]
fn build_llm_headers_uses_panel_intent_for_plain_user_turn() {
let provider = CopilotProvider::with_token("test_token");
let messages = vec![
Message::system("Stable instructions"),
Message::user("hello"),
];
let headers = provider
.build_llm_headers(
"test_token",
&messages,
&[],
request_overrides::ENDPOINT_RESPONSES,
Some("gpt-4o"),
)
.expect("headers");
assert_eq!(headers.get("openai-intent").unwrap(), "conversation-panel");
assert_eq!(headers.get("x-initiator").unwrap(), "user");
}
#[test]
fn build_llm_headers_switches_to_agent_intent_for_tool_enabled_user_turn() {
let provider = CopilotProvider::with_token("test_token");
let messages = vec![
Message::system("Stable instructions"),
Message::user("run a tool"),
];
let tools = vec![ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: "search".to_string(),
description: "Search".to_string(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
}];
let headers = provider
.build_llm_headers(
"test_token",
&messages,
&tools,
request_overrides::ENDPOINT_RESPONSES,
Some("gpt-4o"),
)
.expect("headers");
assert_eq!(headers.get("openai-intent").unwrap(), "conversation-agent");
assert_eq!(headers.get("x-initiator").unwrap(), "user");
}
#[test]
fn copilot_responses_forces_store_false_and_strips_openai_cache_controls() {
let mut effective_responses_options = ResponsesRequestOptions {
store: Some(true),
instructions: Some("Stable instructions".to_string()),
prompt_cache_key: Some("openai-only".to_string()),
prompt_cache_options: Some(json!({"mode": "explicit"})),
raw_input_with_cache_breakpoints: Some(json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "raw",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}])),
..Default::default()
};
if effective_responses_options.store == Some(true) {
effective_responses_options.store = Some(false);
}
effective_responses_options.prompt_cache_key = None;
effective_responses_options.prompt_cache_options = None;
effective_responses_options.raw_input_with_cache_breakpoints = None;
let body = build_responses_body(
"gpt-5.6",
&[Message::user("hello")],
&[],
None,
None,
Some(&effective_responses_options),
None,
None,
);
assert_eq!(body["store"], false);
assert!(body.get("prompt_cache_key").is_none());
assert!(body.get("prompt_cache_options").is_none());
assert_eq!(body["input"][0]["content"], "hello");
}
#[test]
fn detects_previous_response_id_unsupported_errors_for_copilot_responses() {
assert!(
CopilotProvider::looks_like_previous_response_id_unsupported_error(
reqwest::StatusCode::UNPROCESSABLE_ENTITY,
r#"{"error":{"message":"previous_response_id has unsupported_value for this model"}}"#,
)
);
assert!(
!CopilotProvider::looks_like_previous_response_id_unsupported_error(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"message":"invalid input without continuation semantics"}}"#,
)
);
}
#[test]
fn reasoning_error_does_not_trigger_previous_response_fallback() {
assert!(
!CopilotProvider::looks_like_previous_response_id_unsupported_error(
reqwest::StatusCode::BAD_REQUEST,
"reasoning_effort is not supported for this model"
)
);
}
#[test]
fn test_is_authenticated_with_token() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("valid_token");
assert!(provider.is_authenticated());
}
#[test]
fn test_is_authenticated_without_token() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
assert!(!provider.is_authenticated());
}
#[test]
fn test_logout_clears_token() {
if should_skip() {
return;
}
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let mut provider = CopilotProvider::with_token("test_token");
assert!(provider.is_authenticated());
let _ = provider.logout().await;
assert!(provider.token.is_none());
assert!(provider.token_expires_at.is_none());
assert!(!provider.is_authenticated());
Ok::<(), ()>(())
});
assert!(result.is_ok());
}
#[test]
fn test_token_getter() {
if should_skip() {
return;
}
let provider_with_token = CopilotProvider::with_token("my_secret_token");
assert_eq!(provider_with_token.token(), Some("my_secret_token"));
let provider_no_token = CopilotProvider::new();
assert_eq!(provider_no_token.token(), None);
}
#[test]
fn test_request_url() {
if should_skip() {
return;
}
let expected_url = "https://api.githubcopilot.com/chat/completions";
assert!(expected_url.contains("githubcopilot.com"));
assert!(expected_url.contains("chat/completions"));
}
#[test]
fn test_request_body_format() {
if should_skip() {
return;
}
use serde_json::json;
let messages: Vec<Message> = vec![];
let tools: Vec<ToolSchema> = vec![];
let mut body = json!({
"model": "copilot-chat",
"messages": messages,
"messages": messages,
"stream": true,
"tools": tools,
"tool_choice": "auto",
});
assert_eq!(body["model"], "copilot-chat");
assert_eq!(body["stream"], true);
assert_eq!(body["tool_choice"], "auto");
body["max_tokens"] = json!(1000);
assert_eq!(body["max_tokens"], 1000);
}
#[test]
fn test_request_headers_format() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
let headers = provider.build_headers().unwrap();
for (name, value) in headers.iter() {
assert!(
value.to_str().is_ok(),
"Header {} has invalid UTF-8 value",
name
);
}
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(auth.starts_with("Bearer "));
assert!(auth.contains("test_token"));
}
#[test]
fn test_chat_stream_without_auth_fails() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
let rt = tokio::runtime::Runtime::new().unwrap();
let result =
rt.block_on(async { provider.chat_stream(&[], &[], None, "copilot-chat").await });
assert!(result.is_err());
match result {
Err(LLMError::Auth(msg)) => {
assert!(msg.contains("Not authenticated"));
}
_ => panic!("Expected Auth error"),
}
}
#[test]
fn test_build_headers_with_invalid_token() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("");
let result = provider.build_headers();
assert!(result.is_ok());
let headers = result.unwrap();
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert_eq!(auth, "Bearer ");
let long_token = "a".repeat(10000);
let provider_long = CopilotProvider::with_token(long_token.clone());
let result_long = provider_long.build_headers();
assert!(result_long.is_ok());
}
#[test]
fn copilot_provider_has_no_model_field() {
if should_skip() {
return;
}
let provider = CopilotProvider::new();
assert!(provider.token.is_none());
assert!(provider.token_expires_at.is_none());
assert!(!provider.is_authenticated());
}
#[test]
fn copilot_provider_has_no_with_model_method() {
if should_skip() {
return;
}
let provider = CopilotProvider::with_token("test_token");
assert!(provider.is_authenticated());
}
}
#[cfg(test)]
mod operational_log_privacy_tests {
use super::CopilotProvider;
use crate::provider::LLMError;
use crate::providers::common::log_privacy_tests::EventCapture;
#[tokio::test(flavor = "current_thread")]
async fn provider_operational_logs_copilot_retry_keep_url_private_and_error_intact() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let url = format!("http://{address}/private-path-999-sentinel?key=credential-999-sentinel");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let provider = CopilotProvider::new();
let capture = EventCapture::default();
let _guard = tracing::subscriber::set_default(capture.clone());
tracing::callsite::rebuild_interest_cache();
let error = provider
.send_with_transport_retry(
|| client.get(&url),
"responses",
Some("private-session-999-sentinel"),
)
.await
.unwrap_err();
assert!(matches!(error, LLMError::Http(error) if error.url().unwrap().as_str() == url));
capture.assert_private(
"transport error",
&[
"private-path-999-sentinel",
"credential-999-sentinel",
"private-session-999-sentinel",
],
);
}
}
#[cfg(test)]
mod continuous_model_request_tests {
use super::*;
use crate::retry::{
observe_initial_http_retries, with_upstream_retry, InitialHttpRetryObserver, UpstreamRetry,
WorkPhysicalRequest,
};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn continuous_model_posts_retry_transient_statuses_and_keep_terminal_responses_unread() {
for operation in [
"copilot responses initial request",
"copilot chat/completions initial request",
] {
for status in [429, 503, 400, 401, 403, 422] {
let server = MockServer::start().await;
let count = Arc::new(AtomicUsize::new(0));
let hits = count.clone();
Mock::given(method("POST"))
.respond_with(move |_: &wiremock::Request| {
let n = hits.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(if n == 0 { status } else { 200 })
.set_body_string("synthetic unread response")
})
.mount(&server)
.await;
let provider = CopilotProvider::with_token("synthetic-token");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let observer = Arc::new(InitialHttpRetryObserver::default());
let response = with_upstream_retry(
UpstreamRetry::UntilCancelled,
observe_initial_http_retries(
observer.clone(),
provider.send_model_request(
|| client.post(server.uri()),
operation,
Some("synthetic-session"),
),
),
)
.await
.unwrap();
let transient = matches!(status, 429 | 503);
assert_eq!(
response.status().as_u16(),
if transient { 200 } else { status }
);
assert_eq!(response.text().await.unwrap(), "synthetic unread response");
assert_eq!(count.load(Ordering::SeqCst), if transient { 2 } else { 1 });
assert!(observer.request_budget_managed());
assert!(!observer.request_policy_active());
}
}
}
#[tokio::test]
async fn copilot_continuous_long_hint_is_cancellable_and_discovery_remains_bounded() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120"))
.mount(&server)
.await;
let observer = Arc::new(InitialHttpRetryObserver::default());
let observed = observer.clone();
let url = server.uri();
let task = tokio::spawn(async move {
let provider = CopilotProvider::with_token("synthetic-token");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
with_upstream_retry(
UpstreamRetry::UntilCancelled,
observe_initial_http_retries(
observed,
provider.send_model_request(
|| client.post(&url),
"copilot responses initial request",
None,
),
),
)
.await
});
tokio::time::timeout(Duration::from_secs(3), async {
while observer.last().is_none() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(observer.last().unwrap().delay >= Duration::from_secs(120));
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert_eq!(server.received_requests().await.unwrap().len(), 1);
assert!(!observer.request_policy_active());
let provider = CopilotProvider::with_token("synthetic-token");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let discovery = with_upstream_retry(
UpstreamRetry::UntilCancelled,
provider.send_with_transport_retry(
|| client.post(server.uri()),
"copilot models list",
None,
),
)
.await
.unwrap();
assert_eq!(discovery.status(), 429);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn copilot_continuous_mode_keeps_the_work_single_physical_send_guard() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
let provider = CopilotProvider::with_token("synthetic-token");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let scope = WorkPhysicalRequest::default();
with_upstream_retry(
UpstreamRetry::UntilCancelled,
scope.scope(async {
let first = provider
.send_model_request(
|| client.post(server.uri()),
"copilot responses initial request",
None,
)
.await
.unwrap();
assert_eq!(first.status(), 503);
let second = provider
.send_model_request(
|| client.post(server.uri()),
"copilot responses auth-refresh retry",
None,
)
.await
.unwrap_err();
assert!(matches!(second, LLMError::RequestFailed(_)));
}),
)
.await;
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
#[cfg(test)]
mod actual_model_consumer_tests {
use super::*;
use crate::retry::{with_upstream_retry, UpstreamRetry, WorkPhysicalRequest};
use futures::StreamExt;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const MODEL: &str = "controlled-model";
const CHAT_SSE: &str =
"data: {\"choices\":[{\"delta\":{\"content\":\"synthetic reply\"}}]}\n\ndata: [DONE]\n\n";
const RESPONSES_SSE: &str = "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"synthetic reply\"}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"synthetic-response\"}}\n\n";
fn provider(base: String, responses_only: bool) -> CopilotProvider {
let mut provider = CopilotProvider::with_token("synthetic-token");
provider.client = Client::builder().no_proxy().build().unwrap();
provider.test_model_base_url = Some(base);
if responses_only {
provider = provider.with_responses_only_models(vec![MODEL.into()]);
}
provider
}
async fn assert_completed_stream(mut stream: LLMStream) {
let mut text = String::new();
let mut done = 0;
while let Some(item) = stream.next().await {
match item.expect("controlled stream must decode") {
LLMChunk::Token(token) => text.push_str(&token),
LLMChunk::Done => done += 1,
_ => {}
}
}
assert_eq!(text, "synthetic reply");
assert_eq!(done, 1);
}
fn expected_error(result: Result<LLMStream>) -> LLMError {
match result {
Ok(_) => panic!("controlled request must fail before a stream is established"),
Err(error) => error,
}
}
#[test]
fn production_model_endpoints_remain_fixed_without_a_test_override() {
let provider = CopilotProvider::new();
assert_eq!(
provider.model_endpoint("chat/completions"),
"https://api.githubcopilot.com/chat/completions"
);
assert_eq!(
provider.model_endpoint("responses"),
"https://api.githubcopilot.com/responses"
);
}
#[tokio::test]
async fn actual_chat_and_responses_continuously_retry_transient_status_then_consume_sse() {
for (responses_only, endpoint, sse) in [
(false, "/chat/completions", CHAT_SSE),
(true, "/responses", RESPONSES_SSE),
] {
for transient_status in [429, 503] {
let server = MockServer::start().await;
let hits = Arc::new(AtomicUsize::new(0));
let responder_hits = hits.clone();
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(move |_: &wiremock::Request| {
if responder_hits.fetch_add(1, Ordering::SeqCst) == 0 {
ResponseTemplate::new(transient_status)
} else {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(sse)
}
})
.expect(2)
.mount(&server)
.await;
let provider = provider(server.uri(), responses_only);
let messages = [Message::user("controlled user")];
let stream = tokio::time::timeout(
Duration::from_secs(5),
with_upstream_retry(
UpstreamRetry::UntilCancelled,
provider.chat_stream(&messages, &[], Some(64), MODEL),
),
)
.await
.expect("the actual model route must finish")
.expect("the transient response must be retried");
tokio::time::timeout(Duration::from_secs(2), assert_completed_stream(stream))
.await
.expect("the successful SSE stream must finish");
assert_eq!(hits.load(Ordering::SeqCst), 2);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].url.path(), endpoint);
assert_eq!(requests[1].url.path(), endpoint);
assert_eq!(requests[0].body, requests[1].body);
}
}
}
#[tokio::test]
async fn actual_model_terminal_statuses_stop_once_without_an_auth_handler() {
for (responses_only, endpoint) in [(false, "/chat/completions"), (true, "/responses")] {
for status in [400, 401, 403] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(ResponseTemplate::new(status).set_body_string("terminal error"))
.expect(1)
.mount(&server)
.await;
let provider = provider(server.uri(), responses_only);
assert!(provider.auth_handler.is_none());
let messages = [Message::user("controlled user")];
let error = expected_error(
tokio::time::timeout(
Duration::from_secs(5),
with_upstream_retry(
UpstreamRetry::UntilCancelled,
provider.chat_stream(&messages, &[], Some(64), MODEL),
),
)
.await
.expect("a terminal response must finish"),
);
if status == 400 {
assert!(matches!(error, LLMError::Api(_)));
} else {
assert!(matches!(error, LLMError::Auth(_)));
}
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), endpoint);
}
}
}
#[tokio::test]
async fn actual_chat_responses_only_error_falls_back_to_the_responses_route() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(400).set_body_string("use Responses API"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(RESPONSES_SSE),
)
.expect(1)
.mount(&server)
.await;
let provider = provider(server.uri(), false);
let messages = [Message::user("controlled user")];
let stream = tokio::time::timeout(
Duration::from_secs(5),
with_upstream_retry(
UpstreamRetry::UntilCancelled,
provider.chat_stream(&messages, &[], Some(64), MODEL),
),
)
.await
.expect("the compatibility fallback must finish")
.expect("the Responses fallback must succeed");
tokio::time::timeout(Duration::from_secs(2), assert_completed_stream(stream))
.await
.expect("the fallback stream must finish");
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].url.path(), "/chat/completions");
assert_eq!(requests[1].url.path(), "/responses");
}
#[tokio::test]
async fn work_actual_chat_rejection_cannot_send_the_responses_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(400).set_body_string("use Responses API"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(ResponseTemplate::new(200).set_body_string(RESPONSES_SSE))
.expect(0)
.mount(&server)
.await;
let provider = provider(server.uri(), false);
let messages = [Message::user("controlled user")];
let work = WorkPhysicalRequest::default();
let error = expected_error(
tokio::time::timeout(
Duration::from_secs(5),
with_upstream_retry(
UpstreamRetry::UntilCancelled,
work.scope(provider.chat_stream(&messages, &[], Some(64), MODEL)),
),
)
.await
.expect("the Work reservation must reject the fallback"),
);
assert!(matches!(error, LLMError::RequestFailed(ref message)
if message.contains("new Host reservation is required")));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), "/chat/completions");
}
async fn read_complete_post(socket: &mut TcpStream) -> Vec<u8> {
let mut received = Vec::new();
let mut buffer = [0u8; 2048];
let header_end = loop {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0, "peer closed before completing request headers");
received.extend_from_slice(&buffer[..count]);
if let Some(index) = received.windows(4).position(|part| part == b"\r\n\r\n") {
break index + 4;
}
assert!(
received.len() < 65_536,
"controlled request headers exceeded limit"
);
};
let headers = std::str::from_utf8(&received[..header_end]).unwrap();
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.map(|(_, value)| value.trim().parse::<usize>().unwrap())
.expect("controlled JSON POST has a known body size");
while received.len() < header_end + content_length {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0, "peer closed before completing the request body");
received.extend_from_slice(&buffer[..count]);
}
received
}
fn post_body(request: &[u8]) -> &[u8] {
let header_end = request
.windows(4)
.position(|part| part == b"\r\n\r\n")
.expect("controlled POST headers")
+ 4;
&request[header_end..]
}
#[tokio::test]
async fn actual_model_routes_retry_closed_connection_after_complete_post() {
for (responses_only, endpoint, sse) in [
(false, "/chat/completions", CHAT_SSE),
(true, "/responses", RESPONSES_SSE),
] {
for policy in [UpstreamRetry::Bounded, UpstreamRetry::UntilCancelled] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut first, _) = listener.accept().await.unwrap();
let original = read_complete_post(&mut first).await;
assert!(
original.starts_with(format!("POST {endpoint} HTTP/1.1\r\n").as_bytes())
);
drop(first);
let (mut second, _) = listener.accept().await.unwrap();
let retry = read_complete_post(&mut second).await;
assert!(retry.starts_with(format!("POST {endpoint} HTTP/1.1\r\n").as_bytes()));
assert_eq!(post_body(&original), post_body(&retry));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
sse.len(),
sse
);
second.write_all(response.as_bytes()).await.unwrap();
});
let provider = provider(format!("http://{address}"), responses_only);
let messages = [Message::user("controlled user")];
let stream = tokio::time::timeout(
Duration::from_secs(5),
with_upstream_retry(
policy,
provider.chat_stream(&messages, &[], Some(64), MODEL),
),
)
.await
.expect("the initial disconnect retry must finish")
.expect("a closed connection before headers is transient");
tokio::time::timeout(Duration::from_secs(2), assert_completed_stream(stream))
.await
.expect("the retried stream must finish");
tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("the controlled server must finish")
.unwrap();
}
}
}
}