Skip to main content

rig_core/providers/copilot/
mod.rs

1//! GitHub Copilot authentication, endpoint wires, and model identifiers.
2//!
3//! A [`Copilot`] client sends with an exchanged session token. To log in with
4//! a device code or refresh a GitHub token, pass an [`auth::Authenticator`] to
5//! [`Copilot::authenticate`], which resolves the session through the client's
6//! own transport.
7//! Completion routes are selected by model; all routes include editor-identity headers.
8//!
9//! ```no_run
10//! use rig_core::providers::copilot;
11//!
12//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
13//! let github = copilot::Copilot::from_env()?;
14//!
15//! let chat = github.completion(copilot::GPT_4O);
16//! let codex = github.completion(copilot::GPT_5_3_CODEX);
17//! let embeddings = github.embedding(copilot::TEXT_EMBEDDING_3_SMALL, None);
18//! # let _ = (chat, codex, embeddings);
19//! # Ok(())
20//! # }
21//! ```
22
23/// Device login, session-token exchange, refresh, and shared credential caching.
24///
25/// ```no_run
26/// use rig_core::providers::copilot::auth::{AuthSource, Authenticator, DeviceCodeHandler};
27///
28/// let auth = Authenticator::new(AuthSource::OAuth, None, None, DeviceCodeHandler::default(), true);
29/// ```
30pub 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
40/// The API root a token that names no endpoint of its own resolves against.
41pub(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
47/// Catalogue endpoint returning all models without pagination.
48pub(crate) const MODEL_LISTING_PATH: &str = "/models";
49
50/// Stable descriptor name reported on normalized Copilot responses.
51pub const PROVIDER_NAME: &str = "copilot";
52
53/// Conversation intent sent in the `openai-intent` request header.
54#[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
71/// `gpt-4`
72pub const GPT_4: &str = "gpt-4";
73/// `gpt-4o`
74pub const GPT_4O: &str = "gpt-4o";
75/// `gpt-4o-mini`
76pub const GPT_4O_MINI: &str = "gpt-4o-mini";
77/// `gpt-4.1`
78pub const GPT_4_1: &str = "gpt-4.1";
79/// `gpt-4.1-mini`
80pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
81/// `gpt-4.1-nano`
82pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
83/// `gpt-5.3-codex`
84pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
85/// `gpt-5.1-codex`
86pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
87/// `gpt-5.5`
88pub const GPT_5_5: &str = "gpt-5.5";
89/// `gpt-5.4`
90pub const GPT_5_4: &str = "gpt-5.4";
91/// `claude-sonnet-4` completion model (Anthropic, via Copilot)
92pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
93/// `claude-sonnet-4.6`
94pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
95/// `claude-opus-4.6`
96pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
97/// `claude-opus-4.7`
98pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
99/// `claude-3.5-sonnet` completion model (Anthropic, via Copilot)
100pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
101/// `gemini-3-flash-preview` completion model (Google, via Copilot)
102pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
103/// `gemini-3.1-pro-preview` completion model (Google, via Copilot)
104pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
105/// `gemini-2.0-flash-001` completion model (Google, via Copilot)
106pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
107/// `o3-mini` reasoning model (OpenAI, via Copilot)
108pub const O3_MINI: &str = "o3-mini";
109/// `text-embedding-3-small`
110pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
111/// `text-embedding-3-large`
112pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
113/// `text-embedding-ada-002`
114pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
115
116/// The encoding [`wire::Embeddings::with_encoding_format`] asks for. Copilot
117/// relays OpenAI's embeddings wire, so it is OpenAI's own type.
118pub use openai::EncodingFormat;
119
120/// Build required editor-identity headers, including credentials and request identity.
121pub(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
153/// Derive the Copilot REST base URL from a chat token's `proxy-ep=` segment.
154///
155/// The endpoint is parsed from a credential string, not from explicit caller
156/// configuration. For that reason, token-derived routing is limited to GitHub
157/// Copilot service hosts and HTTPS. Callers that need a custom non-GitHub host
158/// can still opt in explicitly with [`wire::Copilot::with_base_url`].
159pub(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
205/// The `X-Initiator` this request declares: `agent` once the turn contains
206/// anything the model itself produced, `user` otherwise.
207pub(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
226/// Whether this request carries an image, which Copilot gates behind
227/// `copilot-vision-request`.
228pub(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/// Copilot's error body, which names the failure under either key.
238#[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;