rig_core/providers/copilot/
mod.rs1pub mod auth;
31pub mod wire;
32
33pub use crate::client::copilot::Copilot;
34pub use wire::CopilotConfig;
35
36use crate::completion;
37use crate::providers::openai;
38use serde::{Deserialize, Serialize};
39
40pub(crate) const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
42pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
43pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
44pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
45const API_VERSION: &str = "2025-04-01";
46
47pub(crate) const MODEL_LISTING_PATH: &str = "/models";
49
50pub const PROVIDER_NAME: &str = "copilot";
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum CopilotIntent {
57 #[default]
58 Panel,
59 Edits,
60}
61
62impl CopilotIntent {
63 pub fn as_header(self) -> &'static str {
64 match self {
65 CopilotIntent::Panel => "conversation-panel",
66 CopilotIntent::Edits => "conversation-edits",
67 }
68 }
69}
70
71pub const GPT_4: &str = "gpt-4";
73pub const GPT_4O: &str = "gpt-4o";
75pub const GPT_4O_MINI: &str = "gpt-4o-mini";
77pub const GPT_4_1: &str = "gpt-4.1";
79pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
81pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
83pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
85pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
87pub const GPT_5_5: &str = "gpt-5.5";
89pub const GPT_5_4: &str = "gpt-5.4";
91pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
93pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
95pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
97pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
99pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
101pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
103pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
105pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
107pub const O3_MINI: &str = "o3-mini";
109pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
111pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
113pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
115
116pub use openai::EncodingFormat;
119
120pub(crate) fn default_headers(
122 api_key: &str,
123 initiator: &'static str,
124 has_vision: bool,
125 intent: CopilotIntent,
126) -> Vec<(&'static str, String)> {
127 let mut headers = vec![
128 (
129 http::header::AUTHORIZATION.as_str(),
130 format!("Bearer {api_key}"),
131 ),
132 ("copilot-integration-id", "vscode-chat".to_string()),
133 ("editor-version", EDITOR_VERSION.to_string()),
134 ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
135 ("user-agent", USER_AGENT.to_string()),
136 ("openai-intent", intent.as_header().to_string()),
137 ("x-github-api-version", API_VERSION.to_string()),
138 ("x-request-id", crate::id::generate()),
139 (
140 "x-vscode-user-agent-library-version",
141 "electron-fetch".to_string(),
142 ),
143 ("X-Initiator", initiator.to_string()),
144 ];
145
146 if has_vision {
147 headers.push(("copilot-vision-request", "true".to_string()));
148 }
149
150 headers
151}
152
153pub(crate) fn base_url_from_token(token: &str) -> Option<String> {
160 let proxy_ep = token
161 .split(';')
162 .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
163 .trim();
164
165 normalize_copilot_proxy_endpoint(proxy_ep)
166}
167
168fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
169 if proxy_ep.is_empty() {
170 return None;
171 }
172
173 let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
174 proxy_ep.to_string()
175 } else {
176 format!("https://{proxy_ep}")
177 };
178
179 let mut url = url::Url::parse(&candidate).ok()?;
180 if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
181 return None;
182 }
183 if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
184 return None;
185 }
186
187 let host = url.host_str()?.to_ascii_lowercase();
188 if !is_allowed_token_derived_copilot_host(&host) {
189 return None;
190 }
191
192 let api_host = host
193 .strip_prefix("proxy.")
194 .map(|suffix| format!("api.{suffix}"))
195 .unwrap_or(host);
196 url.set_host(Some(&api_host)).ok()?;
197
198 Some(url.to_string().trim_end_matches('/').to_string())
199}
200
201fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
202 host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
203}
204
205pub(crate) fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
208 for message in request.chat_history.iter() {
209 match message {
210 crate::completion::Message::Assistant { .. } => return "agent",
211 crate::completion::Message::User { content } => {
212 if content
213 .iter()
214 .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
215 {
216 return "agent";
217 }
218 }
219 crate::completion::Message::System { .. } => {}
220 }
221 }
222
223 "user"
224}
225
226pub(crate) fn request_has_vision(request: &completion::CompletionRequest) -> bool {
229 request.chat_history.iter().any(|message| match message {
230 crate::completion::Message::User { content } => content
231 .iter()
232 .any(|item| matches!(item, crate::message::UserContent::Image(_))),
233 _ => false,
234 })
235}
236
237#[derive(Debug, Deserialize)]
239pub struct ChatApiErrorResponse {
240 #[serde(default)]
241 pub message: Option<String>,
242 #[serde(default)]
243 pub error: Option<String>,
244}
245
246impl ChatApiErrorResponse {
247 pub fn error_message(&self) -> &str {
248 self.message
249 .as_deref()
250 .or(self.error.as_deref())
251 .unwrap_or("unknown error")
252 }
253}
254
255#[cfg(test)]
256mod tests;