1mod auth;
24
25use crate::client::{
26 self, ApiKey, DebugExt, ModelLister, Provider, ProviderBuilder, ProviderClient, Transport,
27};
28use crate::completion::NormalizeCompletionResponse;
29use crate::completion::{self, CompletionError};
30use crate::embeddings::{self, EmbeddingError};
31use crate::http_client::{self, HttpClientExt};
32use crate::model::{Model, ModelList, ModelListingError};
33use crate::providers::internal::completion_send::send_completion;
34use crate::providers::internal::envelope::DirectPayload;
35use crate::providers::openai;
36use crate::providers::openai::responses_api::{self, CompletionRequest as ResponsesRequest};
37use crate::streaming::StreamingCompletionResponse;
38use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
39use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
40use futures::StreamExt;
41use http::Request;
42use serde::{Deserialize, Serialize};
43use serde_json::json;
44use std::borrow::Cow;
45use std::fmt::Debug;
46use std::path::{Path, PathBuf};
47use tracing_futures::Instrument as _;
48
49const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
50pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
51pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
52pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
53const API_VERSION: &str = "2025-04-01";
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum CopilotIntent {
58 #[default]
60 Panel,
61 Edits,
63}
64
65impl CopilotIntent {
66 fn as_header(self) -> &'static str {
67 match self {
68 Self::Panel => "conversation-panel",
69 Self::Edits => "conversation-edits",
70 }
71 }
72}
73
74pub const GPT_4: &str = "gpt-4";
76pub const GPT_4O: &str = "gpt-4o";
78pub const GPT_4O_MINI: &str = "gpt-4o-mini";
80pub const GPT_4_1: &str = "gpt-4.1";
82pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
84pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
86pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
88pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
90pub const GPT_5_5: &str = "gpt-5.5";
92pub const GPT_5_4: &str = "gpt-5.4";
94pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
96pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
98pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
100pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
102pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
104pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
106pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
108pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
110pub const O3_MINI: &str = "o3-mini";
112pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
114pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
116pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
118
119pub use openai::EncodingFormat;
120
121#[derive(Clone)]
122pub enum CopilotAuth {
123 ApiKey(String),
124 GitHubAccessToken(String),
125 OAuth,
126}
127
128impl ApiKey for CopilotAuth {}
129
130impl<S> From<S> for CopilotAuth
131where
132 S: Into<String>,
133{
134 fn from(value: S) -> Self {
135 Self::ApiKey(value.into())
136 }
137}
138
139impl Debug for CopilotAuth {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
143 Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
144 Self::OAuth => f.write_str("OAuth"),
145 }
146 }
147}
148
149#[derive(Debug, Clone)]
150pub struct CopilotBuilder {
151 access_token_file: Option<PathBuf>,
152 api_key_file: Option<PathBuf>,
153 device_code_handler: auth::DeviceCodeHandler,
154 allow_device_flow: bool,
155}
156
157#[derive(Clone)]
158pub struct CopilotExt {
159 auth: auth::Authenticator,
160}
161
162impl Debug for CopilotExt {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.debug_struct("CopilotExt")
165 .field("auth", &self.auth)
166 .finish()
167 }
168}
169
170pub type Client<H = reqwest::Client> = client::Client<CopilotExt, H>;
171pub type ClientBuilder<H = crate::markers::Missing> =
172 client::ClientBuilder<CopilotBuilder, CopilotAuth, H>;
173
174impl Default for CopilotBuilder {
175 fn default() -> Self {
176 let token_dir = default_token_dir();
177 Self {
178 access_token_file: token_dir.as_ref().map(|dir| dir.join("access-token")),
179 api_key_file: token_dir.map(|dir| dir.join("api-key.json")),
180 device_code_handler: auth::DeviceCodeHandler::default(),
181 allow_device_flow: true,
182 }
183 }
184}
185
186impl Provider for CopilotExt {
187 type Builder = CopilotBuilder;
188
189 const VERIFY_PATH: &'static str = "";
190}
191
192client::impl_capabilities!(
193 CopilotExt,
194 completion = CompletionModel<H>,
195 embeddings = EmbeddingModel<H>,
196 model_listing = CopilotModelLister<H>,
197);
198
199impl DebugExt for CopilotExt {}
200
201impl ProviderBuilder for CopilotBuilder {
202 type Extension<H>
203 = CopilotExt
204 where
205 H: HttpClientExt;
206 type ApiKey = CopilotAuth;
207
208 const BASE_URL: &'static str = GITHUB_COPILOT_API_BASE_URL;
209
210 fn build<H>(
211 builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
212 ) -> http_client::Result<Self::Extension<H>>
213 where
214 H: HttpClientExt,
215 {
216 let auth = match builder.get_api_key() {
217 CopilotAuth::ApiKey(api_key) => auth::AuthSource::ApiKey(api_key.clone()),
218 CopilotAuth::GitHubAccessToken(access_token) => {
219 auth::AuthSource::GitHubAccessToken(access_token.clone())
220 }
221 CopilotAuth::OAuth => auth::AuthSource::OAuth,
222 };
223
224 let ext = builder.ext();
225 Ok(CopilotExt {
226 auth: auth::Authenticator::new(
227 auth,
228 ext.access_token_file.clone(),
229 ext.api_key_file.clone(),
230 ext.device_code_handler.clone(),
231 ext.allow_device_flow,
232 ),
233 })
234 }
235}
236
237impl ProviderClient for Client {
238 type Input = CopilotAuth;
239 type Error = crate::client::ProviderClientError;
240
241 fn from_env() -> Result<Self, Self::Error> {
242 let mut builder = Self::builder();
243 fn get(name: &str) -> Option<String> {
244 std::env::var(name).ok()
245 }
246
247 if let Some(base_url) = env_base_url(&get) {
248 builder = builder.base_url(base_url);
249 }
250
251 if let Some(api_key) = env_api_key(&get) {
252 builder.api_key(api_key).build().map_err(Into::into)
253 } else if let Some(access_token) = env_github_access_token(&get) {
254 builder
255 .github_access_token(access_token)
256 .build()
257 .map_err(Into::into)
258 } else {
259 builder.oauth().build().map_err(Into::into)
260 }
261 }
262
263 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
264 Self::builder().api_key(input).build().map_err(Into::into)
265 }
266}
267
268impl<H> client::ClientBuilder<CopilotBuilder, crate::markers::Missing, H> {
269 pub fn github_access_token(
270 self,
271 access_token: impl Into<String>,
272 ) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
273 self.api_key(CopilotAuth::GitHubAccessToken(access_token.into()))
274 }
275
276 pub fn oauth(self) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
277 self.api_key(CopilotAuth::OAuth)
278 }
279}
280
281impl<H> ClientBuilder<H> {
282 pub fn on_device_code<F>(self, handler: F) -> Self
283 where
284 F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
285 {
286 self.over_ext(|mut ext| {
287 ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
288 ext
289 })
290 }
291
292 pub fn allow_device_flow(self, allow: bool) -> Self {
299 self.over_ext(|mut ext| {
300 ext.allow_device_flow = allow;
301 ext
302 })
303 }
304
305 pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
306 let path = path.as_ref();
307 self.over_ext(|mut ext| {
308 ext.access_token_file = Some(path.join("access-token"));
309 ext.api_key_file = Some(path.join("api-key.json"));
310 ext
311 })
312 }
313
314 pub fn access_token_file(self, path: impl AsRef<Path>) -> Self {
315 let path = path.as_ref().to_path_buf();
316 self.over_ext(|mut ext| {
317 ext.access_token_file = Some(path);
318 ext
319 })
320 }
321
322 pub fn api_key_file(self, path: impl AsRef<Path>) -> Self {
323 let path = path.as_ref().to_path_buf();
324 self.over_ext(|mut ext| {
325 ext.api_key_file = Some(path);
326 ext
327 })
328 }
329}
330
331fn env_value<F>(get: &F, name: &str) -> Option<String>
332where
333 F: Fn(&str) -> Option<String>,
334{
335 get(name).filter(|value| !value.trim().is_empty())
336}
337
338fn first_env_value<F>(get: &F, keys: &[&str]) -> Option<String>
339where
340 F: Fn(&str) -> Option<String>,
341{
342 keys.iter().find_map(|key| env_value(get, key))
343}
344
345fn env_api_key<F>(get: &F) -> Option<String>
346where
347 F: Fn(&str) -> Option<String>,
348{
349 first_env_value(get, &["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"])
350}
351
352fn env_github_access_token<F>(get: &F) -> Option<String>
353where
354 F: Fn(&str) -> Option<String>,
355{
356 first_env_value(get, &["COPILOT_GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"])
357}
358
359fn env_base_url<F>(get: &F) -> Option<String>
360where
361 F: Fn(&str) -> Option<String>,
362{
363 first_env_value(get, &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"])
364}
365
366impl<H> Client<H>
367where
368 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
369{
370 pub async fn authorize(&self) -> Result<(), auth::AuthError> {
371 self.ext().auth.auth_context().await.map(|_| ())
372 }
373}
374
375fn default_headers(
376 api_key: &str,
377 initiator: &'static str,
378 has_vision: bool,
379 intent: CopilotIntent,
380) -> Vec<(&'static str, String)> {
381 let mut headers = vec![
382 (
383 http::header::AUTHORIZATION.as_str(),
384 format!("Bearer {api_key}"),
385 ),
386 ("copilot-integration-id", "vscode-chat".to_string()),
387 ("editor-version", EDITOR_VERSION.to_string()),
388 ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
389 ("user-agent", USER_AGENT.to_string()),
390 ("openai-intent", intent.as_header().to_string()),
391 ("x-github-api-version", API_VERSION.to_string()),
392 ("x-request-id", crate::id::generate()),
393 (
394 "x-vscode-user-agent-library-version",
395 "electron-fetch".to_string(),
396 ),
397 ("X-Initiator", initiator.to_string()),
398 ];
399
400 if has_vision {
401 headers.push(("copilot-vision-request", "true".to_string()));
402 }
403
404 headers
405}
406
407fn apply_headers(
408 builder: http_client::Builder,
409 headers: &[(&'static str, String)],
410) -> http_client::Builder {
411 headers
412 .iter()
413 .fold(builder, |builder, (key, value)| builder.header(*key, value))
414}
415
416fn runtime_base_url<'a, H>(client: &'a Client<H>, auth: &'a auth::AuthContext) -> Cow<'a, str> {
417 if client.base_url() != GITHUB_COPILOT_API_BASE_URL {
418 return Cow::Borrowed(client.base_url());
419 }
420
421 if let Some(api_base) = auth.api_base.as_deref() {
422 return Cow::Borrowed(api_base);
423 }
424
425 if let Some(base_url) = base_url_from_token(&auth.api_key) {
426 return Cow::Owned(base_url);
427 }
428
429 Cow::Borrowed(client.base_url())
430}
431
432fn base_url_from_token(token: &str) -> Option<String> {
439 let proxy_ep = token
440 .split(';')
441 .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
442 .trim();
443
444 normalize_copilot_proxy_endpoint(proxy_ep)
445}
446
447fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
448 if proxy_ep.is_empty() {
449 return None;
450 }
451
452 let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
453 proxy_ep.to_string()
454 } else {
455 format!("https://{proxy_ep}")
456 };
457
458 let mut url = url::Url::parse(&candidate).ok()?;
459 if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
460 return None;
461 }
462 if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
463 return None;
464 }
465
466 let host = url.host_str()?.to_ascii_lowercase();
467 if !is_allowed_token_derived_copilot_host(&host) {
468 return None;
469 }
470
471 let api_host = host
472 .strip_prefix("proxy.")
473 .map(|suffix| format!("api.{suffix}"))
474 .unwrap_or(host);
475 url.set_host(Some(&api_host)).ok()?;
476
477 Some(url.to_string().trim_end_matches('/').to_string())
478}
479
480fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
481 host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
482}
483
484fn post_with_auth_base<H>(
485 client: &Client<H>,
486 auth: &auth::AuthContext,
487 path: &str,
488 transport: Transport,
489) -> http_client::Result<http_client::Builder> {
490 let uri = client
491 .ext()
492 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
493 let mut req = Request::post(uri);
494
495 if let Some(headers) = req.headers_mut() {
496 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
497 }
498
499 client.ext().with_custom(req)
500}
501
502fn get_with_auth_base<H>(
503 client: &Client<H>,
504 auth: &auth::AuthContext,
505 path: &str,
506 transport: Transport,
507) -> http_client::Result<http_client::Builder> {
508 let uri = client
509 .ext()
510 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
511 let mut req = Request::get(uri);
512
513 if let Some(headers) = req.headers_mut() {
514 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
515 }
516
517 client.ext().with_custom(req)
518}
519
520fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
521 for message in request.chat_history.iter() {
522 match message {
523 crate::completion::Message::Assistant { .. } => return "agent",
524 crate::completion::Message::User { content } => {
525 if content
526 .iter()
527 .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
528 {
529 return "agent";
530 }
531 }
532 crate::completion::Message::System { .. } => {}
533 }
534 }
535
536 "user"
537}
538
539fn request_has_vision(request: &completion::CompletionRequest) -> bool {
540 request.chat_history.iter().any(|message| match message {
541 crate::completion::Message::User { content } => content
542 .iter()
543 .any(|item| matches!(item, crate::message::UserContent::Image(_))),
544 _ => false,
545 })
546}
547
548struct RequestFacts {
551 initiator: &'static str,
552 has_vision: bool,
553 system_instructions: Option<String>,
554 record_telemetry_content: bool,
555}
556
557impl RequestFacts {
558 fn capture(request: &completion::CompletionRequest) -> Self {
559 Self {
560 initiator: request_initiator(request),
561 has_vision: request_has_vision(request),
562 system_instructions: request.preamble.clone(),
563 record_telemetry_content: request.record_telemetry_content,
564 }
565 }
566}
567
568#[derive(Clone, Copy, Debug, PartialEq, Eq)]
569enum CompletionRoute {
570 ChatCompletions,
571 Responses,
572}
573
574fn route_for_model(model: &str) -> CompletionRoute {
575 if model.to_ascii_lowercase().contains("codex") {
576 CompletionRoute::Responses
577 } else {
578 CompletionRoute::ChatCompletions
579 }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(tag = "api", rename_all = "snake_case")]
584pub enum CopilotCompletionResponse {
585 Chat(Box<openai::completion::CompletionResponse>),
586 Responses(Box<responses_api::CompletionResponse>),
587}
588
589impl NormalizeCompletionResponse for CopilotCompletionResponse {
595 fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
596 match self {
597 Self::Chat(response) => response.normalize(provider),
598 Self::Responses(response) => response.normalize(provider),
599 }
600 }
601}
602
603#[derive(Clone, Serialize, Deserialize)]
604#[serde(tag = "api", rename_all = "snake_case")]
605pub enum CopilotStreamingResponse {
606 Chat(openai::completion::streaming::StreamingCompletionResponse),
607 Responses(responses_api::streaming::StreamingCompletionResponse),
608}
609
610impl From<&CopilotStreamingResponse> for completion::Usage {
611 fn from(response: &CopilotStreamingResponse) -> Self {
612 match response {
613 CopilotStreamingResponse::Chat(response) => (&response.usage).into(),
614 CopilotStreamingResponse::Responses(response) => (&response.usage).into(),
615 }
616 }
617}
618
619impl From<(&str, CopilotStreamingResponse)> for crate::streaming::StreamFinal {
620 fn from((provider, response): (&str, CopilotStreamingResponse)) -> Self {
621 match response {
624 CopilotStreamingResponse::Chat(response) => (provider, response).into(),
625 CopilotStreamingResponse::Responses(response) => (provider, response).into(),
626 }
627 }
628}
629
630pub const PROVIDER_NAME: &str = "copilot";
632
633#[derive(Debug, Deserialize)]
634pub struct ChatApiErrorResponse {
635 #[serde(default)]
636 pub message: Option<String>,
637 #[serde(default)]
638 pub error: Option<String>,
639}
640
641impl ChatApiErrorResponse {
642 pub fn error_message(&self) -> &str {
643 self.message
644 .as_deref()
645 .or(self.error.as_deref())
646 .unwrap_or("unknown error")
647 }
648}
649
650#[derive(Debug, Deserialize)]
651#[serde(untagged)]
652enum ChatApiResponse<T> {
653 Ok(T),
654 Err(ChatApiErrorResponse),
655}
656
657impl<T> crate::providers::internal::envelope::ProviderEnvelope for ChatApiResponse<T> {
658 type Payload = T;
659
660 fn into_payload(self) -> Result<T, String> {
661 match self {
662 Self::Ok(payload) => Ok(payload),
663 Self::Err(error) => Err(error.error_message().to_owned()),
664 }
665 }
666}
667
668#[derive(Clone)]
669pub struct CompletionModel<H = reqwest::Client> {
670 client: Client<H>,
671 pub model: String,
672 pub strict_tools: bool,
673 pub tool_result_array_content: bool,
674 pub intent: CopilotIntent,
675}
676
677impl<H> CompletionModel<H>
678where
679 Client<H>: HttpClientExt + Clone + Debug + 'static,
680 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
681{
682 pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
683 Self {
684 client,
685 model: model.into(),
686 strict_tools: false,
687 tool_result_array_content: false,
688 intent: CopilotIntent::default(),
689 }
690 }
691
692 pub fn with_strict_tools(mut self) -> Self {
693 self.strict_tools = true;
694 self
695 }
696
697 pub fn with_tool_result_array_content(mut self) -> Self {
698 self.tool_result_array_content = true;
699 self
700 }
701
702 pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
704 self.intent = intent;
705 self
706 }
707
708 pub fn with_panel_intent(self) -> Self {
710 self.with_intent(CopilotIntent::Panel)
711 }
712
713 pub fn with_edits_intent(self) -> Self {
715 self.with_intent(CopilotIntent::Edits)
716 }
717
718 fn route(&self) -> CompletionRoute {
719 route_for_model(&self.model)
720 }
721
722 async fn auth_context(&self) -> Result<auth::AuthContext, CompletionError> {
723 self.client
724 .ext()
725 .auth
726 .auth_context()
727 .await
728 .map_err(|err| CompletionError::ProviderError(err.to_string()))
729 }
730
731 fn chat_request(
732 &self,
733 completion_request: completion::CompletionRequest,
734 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
735 openai::completion::CompletionRequest::try_from(openai::completion::OpenAIRequestParams {
736 model: self.model.clone(),
737 request: completion_request,
738 strict_tools: self.strict_tools,
739 tool_result_array_content: self.tool_result_array_content,
740 supports_response_format: true,
741 supports_tools: true,
742 })
743 }
744
745 fn responses_request(
746 &self,
747 completion_request: completion::CompletionRequest,
748 ) -> Result<ResponsesRequest, CompletionError> {
749 let mut request = ResponsesRequest::try_from(responses_api::ResponsesRequestParams {
750 model: self.model.clone(),
751 request: completion_request,
752 system_instructions_placement:
753 responses_api::SystemInstructionsPlacement::InputSystemMessages,
754 })?;
755 request.tools = request
759 .tools
760 .into_iter()
761 .map(responses_api::ResponsesToolDefinition::with_strict)
762 .collect();
763 Ok(request)
764 }
765
766 async fn signed_request(
773 &self,
774 facts: &RequestFacts,
775 path: &str,
776 transport: Transport,
777 model: &str,
778 operation: CompletionOperation,
779 body: Vec<u8>,
780 ) -> Result<(Request<Vec<u8>>, tracing::Span), CompletionError> {
781 let auth = self.auth_context().await?;
782
783 let headers = default_headers(
784 &auth.api_key,
785 facts.initiator,
786 facts.has_vision,
787 self.intent,
788 );
789 let req = apply_headers(
790 post_with_auth_base(&self.client, &auth, path, transport)?,
791 &headers,
792 )
793 .body(body)
794 .map_err(|err| CompletionError::HttpError(err.into()))?;
795
796 let span = CompletionSpanBuilder::new("copilot", model, operation)
797 .system_instructions(
798 facts.system_instructions.as_deref(),
799 facts.record_telemetry_content,
800 )
801 .build();
802
803 Ok((req, span))
804 }
805
806 async fn raw_completion_chat(
810 &self,
811 completion_request: completion::CompletionRequest,
812 ) -> Result<(openai::completion::CompletionResponse, Option<String>), CompletionError> {
813 let facts = RequestFacts::capture(&completion_request);
814 let request = self.chat_request(completion_request)?;
815 let (req, span) = self
816 .signed_request(
817 &facts,
818 "/chat/completions",
819 Transport::Http,
820 &request.model,
821 CompletionOperation::Chat,
822 serde_json::to_vec(&request)?,
823 )
824 .await?;
825
826 send_completion::<_, ChatApiResponse<openai::completion::CompletionResponse>, _>(
827 &self.client,
828 req,
829 "Copilot chat completion",
830 Some("x-request-id"),
834 |response| {
835 let span = tracing::Span::current();
836 span.record_response_metadata(response);
837 let usage = response
838 .usage
839 .as_ref()
840 .map(|usage| usage.to_normalized())
841 .unwrap_or_default();
842 span.record_token_usage(&usage);
843 },
844 )
845 .instrument(span)
846 .await
847 }
848
849 async fn raw_completion_responses(
850 &self,
851 completion_request: completion::CompletionRequest,
852 ) -> Result<responses_api::CompletionResponse, CompletionError> {
853 let facts = RequestFacts::capture(&completion_request);
854 let request = self.responses_request(completion_request)?;
855 let (req, span) = self
856 .signed_request(
857 &facts,
858 "/responses",
859 Transport::Http,
860 &request.model,
861 CompletionOperation::Chat,
862 serde_json::to_vec(&request)?,
863 )
864 .await?;
865
866 send_completion::<_, DirectPayload<responses_api::CompletionResponse>, _>(
867 &self.client,
868 req,
869 "Copilot responses completion",
870 Some("x-request-id"),
872 |response| {
873 let span = tracing::Span::current();
874 span.record("gen_ai.response.id", response.id.as_str());
875 span.record("gen_ai.response.model", response.model.as_str());
876 if let Some(usage) = &response.usage {
877 span.record_token_usage(&usage.into());
878 }
879 },
880 )
881 .instrument(span)
882 .await
883 .map(|(mut payload, provider_request_id)| {
884 payload.provider_request_id = provider_request_id;
885 payload
886 })
887 }
888
889 async fn raw_stream_chat(
890 &self,
891 completion_request: completion::CompletionRequest,
892 ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
893 {
894 let facts = RequestFacts::capture(&completion_request);
895 let request = self.chat_request(completion_request)?;
896 let mut request_json = serde_json::to_value(&request)?;
897 let request_object = request_json.as_object_mut().ok_or_else(|| {
898 CompletionError::ResponseError("copilot request body must be a JSON object".into())
899 })?;
900 request_object.insert("stream".to_owned(), json!(true));
901 request_object.insert(
902 "stream_options".to_owned(),
903 json!({ "include_usage": true }),
904 );
905
906 let (req, span) = self
907 .signed_request(
908 &facts,
909 "/chat/completions",
910 Transport::Sse,
911 &request.model,
912 CompletionOperation::ChatStreaming,
913 serde_json::to_vec(&request_json)?,
914 )
915 .await?;
916
917 tracing::Instrument::instrument(
918 send_copilot_chat_raw_streaming_request(self.client.clone(), req),
919 span,
920 )
921 .await
922 }
923
924 async fn raw_stream_responses(
925 &self,
926 completion_request: completion::CompletionRequest,
927 ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
928 {
929 let facts = RequestFacts::capture(&completion_request);
930 let mut request = self.responses_request(completion_request)?;
931 request.stream = Some(true);
932 let (req, span) = self
933 .signed_request(
934 &facts,
935 "/responses",
936 Transport::Sse,
937 &request.model,
938 CompletionOperation::ChatStreaming,
939 serde_json::to_vec(&request)?,
940 )
941 .await?;
942
943 let client = self.client.clone();
944 let (event_source, request_id_slot) =
946 crate::http_client::sse::GenericEventSource::new(client, req)
947 .capture_request_id("x-request-id");
948
949 let raw = responses_api::streaming::raw_stream_from_event_source(event_source, span);
954 let raw = crate::providers::internal::sse_transport::stamp_terminal_request_id(
955 raw,
956 Some(request_id_slot),
957 Some("x-request-id"),
958 |response, id| response.provider_request_id = Some(id),
959 );
960 let stream = raw.map(|item| {
961 item.and_then(|choice| {
962 choice.try_map_final(|response| Ok(CopilotStreamingResponse::Responses(response)))
963 })
964 });
965
966 Ok(Box::pin(stream))
967 }
968
969 pub async fn raw_completion(
981 &self,
982 completion_request: completion::CompletionRequest,
983 ) -> Result<CopilotCompletionResponse, CompletionError> {
984 self.raw_completion_with_request_id(completion_request)
985 .await
986 .map(|(response, _)| response)
987 }
988
989 pub async fn raw_completion_with_request_id(
1004 &self,
1005 completion_request: completion::CompletionRequest,
1006 ) -> Result<(CopilotCompletionResponse, Option<String>), CompletionError> {
1007 match self.route() {
1008 CompletionRoute::ChatCompletions => self
1009 .raw_completion_chat(completion_request)
1010 .await
1011 .map(|(response, id)| (CopilotCompletionResponse::Chat(Box::new(response)), id)),
1012 CompletionRoute::Responses => self
1013 .raw_completion_responses(completion_request)
1014 .await
1015 .map(|response| {
1016 let id = response.provider_request_id.clone();
1017 (CopilotCompletionResponse::Responses(Box::new(response)), id)
1018 }),
1019 }
1020 }
1021
1022 pub async fn raw_stream(
1025 &self,
1026 completion_request: completion::CompletionRequest,
1027 ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
1028 {
1029 match self.route() {
1030 CompletionRoute::ChatCompletions => self.raw_stream_chat(completion_request).await,
1031 CompletionRoute::Responses => self.raw_stream_responses(completion_request).await,
1032 }
1033 }
1034
1035 async fn stream_normalized(
1038 &self,
1039 completion_request: completion::CompletionRequest,
1040 ) -> Result<StreamingCompletionResponse, CompletionError> {
1041 let raw = self.raw_stream(completion_request).await?;
1042
1043 Ok(StreamingCompletionResponse::stream(
1044 PROVIDER_NAME,
1045 crate::streaming::normalize_stream(
1046 raw,
1047 |response| Ok((PROVIDER_NAME, response).into()),
1048 ),
1049 ))
1050 }
1051}
1052
1053impl<H> crate::client::ConstructCompletionModel<Client<H>> for CompletionModel<H>
1054where
1055 Client<H>: HttpClientExt + Clone + Debug + 'static,
1056 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1057{
1058 fn construct(client: &Client<H>, model: String) -> Self {
1059 Self::new(client.clone(), model)
1060 }
1061}
1062
1063impl<H> completion::CompletionModel for CompletionModel<H>
1064where
1065 Client<H>: HttpClientExt + Clone + Debug + 'static,
1066 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1067{
1068 async fn completion(
1069 &self,
1070 completion_request: completion::CompletionRequest,
1071 ) -> Result<completion::CompletionResponse, CompletionError> {
1072 let (response, provider_request_id) = self
1076 .raw_completion_with_request_id(completion_request)
1077 .await?;
1078 let captured = serde_json::to_value(&response)?;
1079 Ok(response
1080 .normalize(PROVIDER_NAME)?
1081 .with_optional_provider_request_id(provider_request_id)
1082 .with_raw(captured))
1083 }
1084
1085 async fn stream(
1086 &self,
1087 completion_request: completion::CompletionRequest,
1088 ) -> Result<StreamingCompletionResponse, CompletionError> {
1089 self.stream_normalized(completion_request).await
1090 }
1091}
1092
1093#[derive(Clone)]
1094pub struct EmbeddingModel<H = reqwest::Client> {
1095 client: Client<H>,
1096 pub model: String,
1097 pub encoding_format: Option<openai::EncodingFormat>,
1098 pub user: Option<String>,
1099 ndims: usize,
1100}
1101
1102#[derive(Deserialize)]
1103struct CopilotEmbeddingResponse {
1104 data: Vec<CopilotEmbeddingData>,
1105 #[serde(default)]
1107 usage: Option<openai::completion::Usage>,
1108}
1109
1110#[derive(Deserialize)]
1111struct CopilotEmbeddingData {
1112 embedding: Vec<serde_json::Number>,
1113}
1114
1115impl<H> EmbeddingModel<H>
1116where
1117 Client<H>: HttpClientExt + Clone + Debug + 'static,
1118 H: Clone + Default + Debug + 'static,
1119{
1120 pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1121 Self {
1122 client,
1123 model: model.into(),
1124 encoding_format: None,
1125 user: None,
1126 ndims,
1127 }
1128 }
1129}
1130
1131impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1132where
1133 Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1134 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1135{
1136 const MAX_DOCUMENTS: usize = 1024;
1137 type Client = Client<H>;
1138
1139 fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1140 let model = model.into();
1141 let dims = ndims.unwrap_or(match model.as_str() {
1142 TEXT_EMBEDDING_3_LARGE => 3072,
1143 TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1144 _ => 0,
1145 });
1146 Self::new(client.clone(), model, dims)
1147 }
1148
1149 fn ndims(&self) -> usize {
1150 self.ndims
1151 }
1152
1153 async fn embed_texts(
1154 &self,
1155 documents: impl IntoIterator<Item = String>,
1156 ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1157 let documents = documents.into_iter().collect::<Vec<_>>();
1158 let response = self.embed_texts_with_usage(documents).await?;
1159 Ok(response.embeddings)
1160 }
1161
1162 async fn embed_texts_with_usage(
1163 &self,
1164 documents: impl IntoIterator<Item = String>,
1165 ) -> Result<embeddings::EmbeddingResponse, EmbeddingError> {
1166 let documents = documents.into_iter().collect::<Vec<_>>();
1167 let auth = self
1168 .client
1169 .ext()
1170 .auth
1171 .auth_context()
1172 .await
1173 .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1174
1175 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1176 let mut body = json!({
1177 "model": self.model,
1178 "input": documents,
1179 });
1180
1181 let body_object = body.as_object_mut().ok_or_else(|| {
1182 EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1183 })?;
1184
1185 if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1186 body_object.insert("dimensions".to_owned(), json!(self.ndims));
1187 }
1188 if let Some(encoding_format) = &self.encoding_format {
1189 body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1190 }
1191 if let Some(user) = &self.user {
1192 body_object.insert("user".to_owned(), json!(user));
1193 }
1194
1195 let req = apply_headers(
1196 post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1197 &headers,
1198 )
1199 .body(serde_json::to_vec(&body)?)
1200 .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1201
1202 let response = self.client.send(req).await?;
1203 let status = response.status();
1204 if status.is_success() {
1205 let body: Vec<u8> = response.into_body().await?;
1206 #[derive(Deserialize)]
1207 struct NestedApiError {
1208 error: NestedApiErrorMessage,
1209 }
1210
1211 #[derive(Deserialize)]
1212 struct NestedApiErrorMessage {
1213 message: String,
1214 }
1215
1216 let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1217 Ok(parsed) => parsed,
1218 Err(parse_error) => {
1219 if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1220 tracing::warn!(message = %err.error.message, "provider returned an error response");
1221 return Err(EmbeddingError::from_http_response(
1222 status,
1223 String::from_utf8_lossy(&body).into_owned(),
1224 ));
1225 }
1226
1227 let preview = String::from_utf8_lossy(&body);
1228 let preview = if preview.len() > 512 {
1229 format!("{}...", &preview[..512])
1230 } else {
1231 preview.into_owned()
1232 };
1233
1234 return Err(EmbeddingError::ProviderError(format!(
1235 "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1236 )));
1237 }
1238 };
1239
1240 let usage = body
1243 .usage
1244 .as_ref()
1245 .map(|usage| usage.to_normalized())
1246 .unwrap_or_default();
1247
1248 let embeddings = body
1249 .data
1250 .into_iter()
1251 .zip(documents.into_iter())
1252 .map(|(embedding, document)| embeddings::Embedding {
1253 document,
1254 vec: embedding
1255 .embedding
1256 .into_iter()
1257 .filter_map(|n| n.as_f64())
1258 .collect(),
1259 })
1260 .collect();
1261
1262 Ok(embeddings::EmbeddingResponse { embeddings, usage })
1263 } else {
1264 let text = http_client::text(response).await?;
1265 Err(EmbeddingError::from_http_response(status, text))
1266 }
1267 }
1268}
1269
1270const MODEL_LISTING_PATH: &str = "/models";
1271const MODEL_LISTING_PROVIDER: &str = "Copilot";
1272
1273#[derive(Debug, Deserialize)]
1274struct ListModelsResponse {
1275 data: Vec<ListModelEntry>,
1276}
1277
1278#[derive(Debug, Deserialize)]
1279struct ListModelEntry {
1280 id: String,
1281 #[serde(default)]
1282 name: Option<String>,
1283 #[serde(default)]
1284 vendor: Option<String>,
1285 #[serde(default)]
1286 capabilities: Option<ListModelEntryCapabilities>,
1287}
1288
1289#[derive(Debug, Deserialize)]
1290struct ListModelEntryCapabilities {
1291 #[serde(default, rename = "type")]
1292 r#type: Option<String>,
1293}
1294
1295impl From<ListModelEntry> for Model {
1296 fn from(value: ListModelEntry) -> Self {
1297 let mut model = Model::from_id(value.id);
1298 model.name = value.name;
1299 model.owned_by = value.vendor;
1300 if let Some(caps) = value.capabilities {
1301 model.r#type = caps.r#type;
1302 }
1303 model
1304 }
1305}
1306
1307#[derive(Clone)]
1309pub struct CopilotModelLister<H = reqwest::Client> {
1310 client: Client<H>,
1311}
1312
1313impl<H> ModelLister<H> for CopilotModelLister<H>
1314where
1315 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1316{
1317 type Client = Client<H>;
1318
1319 fn new(client: Self::Client) -> Self {
1320 Self { client }
1321 }
1322
1323 async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1324 let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1325 ModelListingError::AuthError {
1326 message: err.to_string(),
1327 }
1328 })?;
1329
1330 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1331 let req = apply_headers(
1332 get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1333 &headers,
1334 )
1335 .body(http_client::NoBody)?;
1336
1337 let response = self.client.send::<_, Vec<u8>>(req).await.map_err(|error| {
1338 crate::providers::internal::model_listing::map_transport_error(
1339 MODEL_LISTING_PROVIDER,
1340 MODEL_LISTING_PATH,
1341 error,
1342 )
1343 })?;
1344
1345 let api_resp: ListModelsResponse =
1346 crate::providers::internal::model_listing::decode_json_response(
1347 response,
1348 MODEL_LISTING_PROVIDER,
1349 MODEL_LISTING_PATH,
1350 )
1351 .await?;
1352 let models = api_resp.data.into_iter().map(Model::from).collect();
1353
1354 Ok(ModelList::new(models))
1355 }
1356}
1357
1358async fn send_copilot_chat_raw_streaming_request<T>(
1359 http_client: T,
1360 req: Request<Vec<u8>>,
1361) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
1362where
1363 T: HttpClientExt + Clone + 'static,
1364{
1365 let raw =
1371 openai::completion::streaming::send_compatible_raw_streaming_request(http_client, req)
1372 .await?;
1373 let stream = raw.map(|item| {
1374 item.and_then(|choice| {
1375 choice.try_map_final(|response| Ok(CopilotStreamingResponse::Chat(response)))
1376 })
1377 });
1378
1379 Ok(Box::pin(stream))
1380}
1381
1382fn default_token_dir() -> Option<PathBuf> {
1383 config_dir().map(|dir| dir.join("github_copilot"))
1384}
1385
1386use crate::providers::internal::auth::config_dir;
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::{
1391 ChatApiErrorResponse, Client, CompletionRoute, CopilotIntent, TEXT_EMBEDDING_3_SMALL,
1392 base_url_from_token, default_headers, env_api_key, env_base_url, env_github_access_token,
1393 route_for_model,
1394 };
1395 use crate::client::CompletionClient;
1396 use crate::completion::CompletionModel;
1397 use crate::http_client;
1398 use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1399 sse_bytes_from_data_lines, sse_bytes_from_json_events,
1400 };
1401 use crate::providers::openai;
1402 use crate::streaming::StreamedAssistantContent;
1403 use crate::test_utils::MockStreamingClient;
1404 use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1405 use futures::StreamExt;
1406 use std::collections::HashMap;
1407
1408 fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1409 entries
1410 .iter()
1411 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1412 .collect()
1413 }
1414
1415 fn minimal_chat_response() -> &'static str {
1416 r#"{
1417 "id": "chatcmpl-123",
1418 "model": "gpt-4o",
1419 "choices": [{
1420 "index": 0,
1421 "message": {
1422 "role": "assistant",
1423 "content": "hello"
1424 },
1425 "finish_reason": "stop"
1426 }],
1427 "usage": {
1428 "prompt_tokens": 4,
1429 "total_tokens": 7
1430 }
1431 }"#
1432 }
1433
1434 fn minimal_responses_response() -> &'static str {
1435 r#"{
1436 "id": "resp_123",
1437 "object": "response",
1438 "created_at": 1700000000,
1439 "status": "completed",
1440 "error": null,
1441 "incomplete_details": null,
1442 "instructions": null,
1443 "max_output_tokens": null,
1444 "model": "gpt-5.3-codex",
1445 "usage": {
1446 "input_tokens": 4,
1447 "input_tokens_details": {
1448 "cached_tokens": 0
1449 },
1450 "output_tokens": 3,
1451 "output_tokens_details": {
1452 "reasoning_tokens": 0
1453 },
1454 "total_tokens": 7
1455 },
1456 "output": [{
1457 "type": "message",
1458 "id": "msg_123",
1459 "role": "assistant",
1460 "status": "completed",
1461 "content": [{
1462 "type": "output_text",
1463 "text": "hello"
1464 }]
1465 }],
1466 "tools": []
1467 }"#
1468 }
1469
1470 fn minimal_embeddings_response() -> &'static str {
1471 r#"{
1472 "data": [
1473 {
1474 "embedding": [0.1, 0.2, 0.3]
1475 },
1476 {
1477 "embedding": [0.4, 0.5, 0.6]
1478 }
1479 ]
1480 }"#
1481 }
1482
1483 #[test]
1484 fn deserialize_standard_openai_response() {
1485 let json = r#"{
1486 "id": "chatcmpl-abc123",
1487 "object": "chat.completion",
1488 "created": 1700000000,
1489 "model": "gpt-4o",
1490 "choices": [{
1491 "index": 0,
1492 "message": {
1493 "role": "assistant",
1494 "content": "Hello!"
1495 },
1496 "finish_reason": "stop"
1497 }],
1498 "usage": {
1499 "prompt_tokens": 10,
1500 "completion_tokens": 5,
1501 "total_tokens": 15
1502 }
1503 }"#;
1504
1505 let response: openai::completion::CompletionResponse =
1506 serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1507 assert_eq!(response.id, "chatcmpl-abc123");
1508 assert_eq!(response.object, "chat.completion");
1509 assert_eq!(response.created, 1700000000);
1510 assert_eq!(response.model, "gpt-4o");
1511 assert_eq!(response.choices.len(), 1);
1512 assert_eq!(response.choices[0].finish_reason, "stop");
1513 }
1514
1515 #[test]
1516 fn deserialize_copilot_response_without_object_and_created() {
1517 let response: openai::completion::CompletionResponse =
1518 serde_json::from_str(minimal_chat_response())
1519 .expect("Copilot response should deserialize");
1520
1521 assert_eq!(response.id, "chatcmpl-123");
1522 assert_eq!(response.object, "");
1523 assert_eq!(response.created, 0);
1524 assert_eq!(response.model, "gpt-4o");
1525 assert_eq!(response.choices.len(), 1);
1526 }
1527
1528 #[test]
1529 fn deserialize_copilot_response_without_finish_reason() {
1530 let json = r#"{
1531 "id": "chatcmpl-claude-001",
1532 "model": "claude-3.5-sonnet",
1533 "choices": [{
1534 "message": {
1535 "role": "assistant",
1536 "content": "Here is my analysis."
1537 }
1538 }],
1539 "usage": {
1540 "prompt_tokens": 50,
1541 "total_tokens": 80
1542 }
1543 }"#;
1544
1545 let response: openai::completion::CompletionResponse =
1546 serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1547
1548 assert_eq!(response.model, "claude-3.5-sonnet");
1549 assert_eq!(response.choices[0].finish_reason, "");
1550 assert_eq!(response.choices[0].index, 0);
1551 }
1552
1553 #[test]
1554 fn error_response_with_message_field() {
1555 let json = r#"{"message": "rate limit exceeded"}"#;
1556 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1557
1558 assert_eq!(err.error_message(), "rate limit exceeded");
1559 }
1560
1561 #[test]
1562 fn error_response_with_error_field() {
1563 let json = r#"{"error": "model not found"}"#;
1564 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1565
1566 assert_eq!(err.error_message(), "model not found");
1567 }
1568
1569 #[test]
1570 fn routes_codex_models_to_responses() {
1571 assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1572 assert_eq!(
1573 route_for_model("gpt-5.1-CODEX-mini"),
1574 CompletionRoute::Responses
1575 );
1576 assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1577 assert_eq!(
1578 route_for_model("claude-sonnet-4.5"),
1579 CompletionRoute::ChatCompletions
1580 );
1581 }
1582
1583 #[test]
1584 fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1585 let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1586 assert_eq!(
1587 panel_headers
1588 .iter()
1589 .find(|(name, _)| *name == "openai-intent")
1590 .map(|(_, value)| value.as_str()),
1591 Some("conversation-panel")
1592 );
1593
1594 let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1595 assert_eq!(
1596 edits_headers
1597 .iter()
1598 .find(|(name, _)| *name == "openai-intent")
1599 .map(|(_, value)| value.as_str()),
1600 Some("conversation-edits")
1601 );
1602 }
1603
1604 #[test]
1605 fn copilot_completion_model_intent_builders_update_intent() {
1606 let client = Client::builder()
1607 .api_key("copilot-token")
1608 .build()
1609 .expect("build client");
1610
1611 let default_model = client.completion_model("gpt-4o");
1612 assert_eq!(default_model.intent.as_header(), "conversation-panel");
1613
1614 let edits_model = client
1615 .completion_model("gpt-4o")
1616 .with_intent(CopilotIntent::Edits);
1617 assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1618
1619 let panel_model = client
1620 .completion_model("gpt-4o")
1621 .with_edits_intent()
1622 .with_panel_intent();
1623 assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1624 }
1625
1626 #[test]
1627 fn base_url_from_token_derives_api_endpoint() {
1628 assert_eq!(
1629 base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1630 .as_deref(),
1631 Some("https://api.individual.githubcopilot.com")
1632 );
1633 assert_eq!(
1634 base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1635 .as_deref(),
1636 Some("https://api.individual.githubcopilot.com")
1637 );
1638 assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1639 }
1640
1641 #[test]
1642 fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1643 assert_eq!(
1644 base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1645 None
1646 );
1647 assert_eq!(
1648 base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1649 None
1650 );
1651 assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1652 assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1653 assert_eq!(
1654 base_url_from_token(
1655 "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1656 ),
1657 None
1658 );
1659 }
1660
1661 #[tokio::test]
1662 async fn api_key_with_proxy_endpoint_overrides_base_url() {
1663 let http_client = RecordingHttpClient::new(minimal_chat_response());
1664 let client = Client::builder()
1665 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1666 .http_client(http_client.clone())
1667 .build()
1668 .expect("build client");
1669 let model = client.completion_model("gpt-4o");
1670 let request = model.completion_request("hello").build();
1671
1672 let _response = model.completion(request).await.expect("chat completion");
1673
1674 let requests = http_client.requests();
1675 assert_eq!(requests.len(), 1);
1676 assert!(
1677 requests[0]
1678 .uri
1679 .starts_with("https://api.individual.githubcopilot.com"),
1680 "expected proxy-derived base URL, got {}",
1681 requests[0].uri
1682 );
1683 }
1684
1685 #[tokio::test]
1686 async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1687 let http_client = RecordingHttpClient::new(minimal_chat_response());
1688 let client = Client::builder()
1689 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1690 .base_url("https://custom.example.com")
1691 .http_client(http_client.clone())
1692 .build()
1693 .expect("build client");
1694 let model = client.completion_model("gpt-4o");
1695 let request = model.completion_request("hello").build();
1696
1697 let _response = model.completion(request).await.expect("chat completion");
1698
1699 let requests = http_client.requests();
1700 assert_eq!(requests.len(), 1);
1701 assert!(
1702 requests[0].uri.starts_with("https://custom.example.com"),
1703 "expected explicit base URL, got {}",
1704 requests[0].uri
1705 );
1706 }
1707
1708 #[tokio::test]
1709 async fn completion_model_edits_intent_sets_request_header() {
1710 let http_client = RecordingHttpClient::new(minimal_chat_response());
1711 let client = Client::builder()
1712 .api_key("copilot-token")
1713 .http_client(http_client.clone())
1714 .build()
1715 .expect("build client");
1716 let model = client.completion_model("gpt-4o").with_edits_intent();
1717 let request = model.completion_request("hello").build();
1718
1719 let _response = model.completion(request).await.expect("chat completion");
1720
1721 let requests = http_client.requests();
1722 assert_eq!(requests.len(), 1);
1723 assert_eq!(
1724 requests[0]
1725 .headers
1726 .get("openai-intent")
1727 .and_then(|value| value.to_str().ok()),
1728 Some("conversation-edits")
1729 );
1730 }
1731
1732 #[tokio::test]
1733 async fn completion_model_routes_chat_requests_to_chat_completions() {
1734 let http_client = RecordingHttpClient::new(minimal_chat_response());
1735 let client = Client::builder()
1736 .api_key("copilot-token")
1737 .http_client(http_client.clone())
1738 .build()
1739 .expect("build client");
1740 let model = client.completion_model("gpt-4o");
1741 let request = model.completion_request("hello").build();
1742
1743 let _response = model.completion(request).await.expect("chat completion");
1744
1745 let requests = http_client.requests();
1746 assert_eq!(requests.len(), 1);
1747 assert!(requests[0].uri.ends_with("/chat/completions"));
1748 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
1749 }
1750
1751 #[tokio::test]
1752 async fn completion_model_routes_codex_requests_to_responses() {
1753 let http_client = RecordingHttpClient::new(minimal_responses_response());
1754 let client = Client::builder()
1755 .api_key("copilot-token")
1756 .http_client(http_client.clone())
1757 .build()
1758 .expect("build client");
1759 let model = client.completion_model("gpt-5.3-codex");
1760 let request = model.completion_request("hello").build();
1761
1762 let _response = model
1763 .completion(request)
1764 .await
1765 .expect("responses completion");
1766
1767 let requests = http_client.requests();
1768 assert_eq!(requests.len(), 1);
1769 assert!(requests[0].uri.ends_with("/responses"));
1770 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
1771 }
1772
1773 #[tokio::test]
1774 async fn embeddings_accept_minimal_copilot_response_shape() {
1775 use crate::client::EmbeddingsClient;
1776 use crate::embeddings::EmbeddingModel as _;
1777
1778 let http_client = RecordingHttpClient::new(minimal_embeddings_response());
1779 let client = Client::builder()
1780 .api_key("copilot-token")
1781 .http_client(http_client.clone())
1782 .build()
1783 .expect("build client");
1784 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
1785
1786 let embeddings = model
1787 .embed_texts(["one".to_string(), "two".to_string()])
1788 .await
1789 .expect("embeddings should deserialize");
1790
1791 assert_eq!(embeddings.len(), 2);
1792 assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
1793 assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
1794
1795 let requests = http_client.requests();
1796 assert_eq!(requests.len(), 1);
1797 assert!(requests[0].uri.ends_with("/embeddings"));
1798 assert!(
1799 String::from_utf8_lossy(&requests[0].body)
1800 .contains("\"model\":\"text-embedding-3-small\"")
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn responses_stream_terminates_after_terminal_error() {
1806 let tool_call_done = serde_json::json!({
1807 "type": "response.output_item.done",
1808 "output_index": 0,
1809 "sequence_number": 1,
1810 "item": {
1811 "type": "function_call",
1812 "id": "fc_123",
1813 "arguments": "{}",
1814 "call_id": "call_123",
1815 "name": "example_tool",
1816 "status": "completed"
1817 }
1818 });
1819 let failed = serde_json::json!({
1820 "type": "response.failed",
1821 "sequence_number": 2,
1822 "response": {
1823 "id": "resp_123",
1824 "object": "response",
1825 "created_at": 1700000000,
1826 "status": "failed",
1827 "error": {
1828 "code": "server_error",
1829 "message": "Copilot response stream failed"
1830 },
1831 "incomplete_details": null,
1832 "instructions": null,
1833 "max_output_tokens": null,
1834 "model": "gpt-5.3-codex",
1835 "usage": null,
1836 "output": [],
1837 "tools": []
1838 }
1839 });
1840 let http_client = MockStreamingClient {
1841 sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
1842 };
1843 let client = Client::builder()
1844 .api_key("copilot-token")
1845 .http_client(http_client)
1846 .build()
1847 .expect("build client");
1848 let model = client.completion_model("gpt-5.3-codex");
1849 let request = model.completion_request("hello").build();
1850 let mut stream = model.stream(request).await.expect("stream should start");
1851
1852 let tool_call = stream
1856 .next()
1857 .await
1858 .expect("fully-delivered tool call should be flushed before the error")
1859 .expect("flushed tool call should not be an error");
1860 assert!(
1861 matches!(
1862 tool_call,
1863 StreamedAssistantContent::ToolCall { ref tool_call, .. }
1864 if tool_call.function.name == "example_tool"
1865 ),
1866 "expected the flushed tool call, got {tool_call:?}"
1867 );
1868 let err = match stream.next().await.expect("stream should yield an item") {
1869 Ok(item) => panic!("stream should surface a provider error, got {item:?}"),
1870 Err(err) => err,
1871 };
1872 assert!(matches!(
1877 err,
1878 crate::completion::CompletionError::ProviderResponse(_)
1879 ));
1880 assert_eq!(err.provider_response_status(), None);
1881 let json = err
1882 .provider_response_json()
1883 .expect("preserved body should parse as JSON")
1884 .expect("preserved body should not be empty");
1885 let response_error = json
1886 .get("response")
1887 .and_then(|response| response.get("error"))
1888 .expect("preserved body should retain the provider error object");
1889 assert_eq!(
1890 response_error.get("code").and_then(|value| value.as_str()),
1891 Some("server_error")
1892 );
1893 assert_eq!(
1894 response_error
1895 .get("message")
1896 .and_then(|value| value.as_str()),
1897 Some("Copilot response stream failed")
1898 );
1899 assert!(
1900 stream.next().await.is_none(),
1901 "responses stream should end without a terminal record after a terminal error"
1902 );
1903 }
1904
1905 #[tokio::test]
1906 async fn responses_stream_object_less_failed_still_attaches_the_raw_event() {
1907 let failed = serde_json::json!({
1914 "type": "response.failed",
1915 "sequence_number": 1,
1916 "response": {
1917 "id": "resp_123",
1918 "object": "response",
1919 "created_at": 1700000000,
1920 "status": "failed",
1921 "error": null,
1922 "incomplete_details": null,
1923 "instructions": null,
1924 "max_output_tokens": null,
1925 "model": "gpt-5.3-codex",
1926 "usage": null,
1927 "output": [],
1928 "tools": []
1929 }
1930 });
1931 let http_client = MockStreamingClient {
1932 sse_bytes: sse_bytes_from_json_events(&[failed]),
1933 };
1934 let client = Client::builder()
1935 .api_key("copilot-token")
1936 .http_client(http_client)
1937 .build()
1938 .expect("build client");
1939 let model = client.completion_model("gpt-5.3-codex");
1940 let request = model.completion_request("hello").build();
1941 let mut stream = model.stream(request).await.expect("stream should start");
1942
1943 let err = match stream.next().await.expect("stream should yield an item") {
1944 Ok(item) => panic!("stream should surface a provider error, got {item:?}"),
1945 Err(err) => err,
1946 };
1947 assert!(matches!(
1948 err,
1949 crate::completion::CompletionError::ProviderResponse(_)
1950 ));
1951 assert_eq!(err.provider_response_status(), None);
1952 assert!(
1953 err.provider_response_body()
1954 .is_some_and(|body| body.contains("response.failed")),
1955 "an object-less response.failed must still carry the raw event body"
1956 );
1957 assert!(
1958 stream.next().await.is_none(),
1959 "responses stream should end after the terminal error"
1960 );
1961 }
1962
1963 #[tokio::test]
1964 async fn responses_stream_incomplete_is_a_terminal_with_partial_content() {
1965 let text_delta = serde_json::json!({
1968 "type": "response.output_text.delta",
1969 "content_index": 0,
1970 "delta": "partial",
1971 "item_id": "msg_1",
1972 "logprobs": [],
1973 "output_index": 0,
1974 "sequence_number": 1
1975 });
1976 let incomplete = serde_json::json!({
1977 "type": "response.incomplete",
1978 "sequence_number": 2,
1979 "response": {
1980 "id": "resp_123",
1981 "object": "response",
1982 "created_at": 1700000000,
1983 "status": "incomplete",
1984 "error": null,
1985 "incomplete_details": { "reason": "max_output_tokens" },
1986 "instructions": null,
1987 "max_output_tokens": null,
1988 "model": "gpt-5.3-codex",
1989 "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 },
1990 "output": [],
1991 "tools": []
1992 }
1993 });
1994 let http_client = MockStreamingClient {
1995 sse_bytes: sse_bytes_from_json_events(&[text_delta, incomplete]),
1996 };
1997 let client = Client::builder()
1998 .api_key("copilot-token")
1999 .http_client(http_client)
2000 .build()
2001 .expect("build client");
2002 let model = client.completion_model("gpt-5.3-codex");
2003 let request = model.completion_request("hello").build();
2004 let mut stream = model.stream(request).await.expect("stream should start");
2005
2006 let mut text = String::new();
2007 let mut terminal = None;
2008 while let Some(item) = stream.next().await {
2009 match item.expect("incomplete turn should not surface an error") {
2010 StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
2011 StreamedAssistantContent::Final(final_response) => terminal = Some(final_response),
2012 other => panic!("unexpected stream item: {other:?}"),
2013 }
2014 }
2015
2016 assert_eq!(text, "partial");
2017 let terminal = terminal.expect("incomplete turn should emit a terminal record");
2018 assert_eq!(
2019 terminal.finish_reason,
2020 Some(crate::completion::FinishReason::Length)
2021 );
2022 assert_eq!(terminal.usage.input_tokens, 1);
2023 assert_eq!(terminal.usage.output_tokens, 2);
2024 assert_eq!(terminal.usage.total_tokens, 3);
2025 }
2026
2027 #[tokio::test]
2028 async fn chat_stream_surfaces_malformed_frame_and_still_completes() {
2029 let http_client = MockStreamingClient {
2030 sse_bytes: sse_bytes_from_data_lines([
2031 "{\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":null}",
2032 "{not valid json",
2033 "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2034 "[DONE]",
2035 ]),
2036 };
2037 let client = Client::builder()
2038 .api_key("copilot-token")
2039 .http_client(http_client)
2040 .build()
2041 .expect("build client");
2042 let model = client.completion_model("gpt-4o");
2043 let request = model.completion_request("hello").build();
2044 let mut stream = model.stream(request).await.expect("stream should start");
2045
2046 let mut text = String::new();
2047 let mut saw_error = false;
2048 let mut terminal = None;
2049 while let Some(item) = stream.next().await {
2050 match item {
2051 Ok(StreamedAssistantContent::Text(chunk)) => text.push_str(&chunk.text),
2052 Ok(StreamedAssistantContent::Final(final_response)) => {
2053 terminal = Some(final_response)
2054 }
2055 Ok(other) => panic!("unexpected stream item: {other:?}"),
2056 Err(err) => {
2057 assert!(
2058 matches!(err, crate::completion::CompletionError::JsonError(_)),
2059 "expected a JSON parse error item, got {err:?}"
2060 );
2061 saw_error = true;
2062 }
2063 }
2064 }
2065
2066 assert_eq!(text, "hello");
2069 assert!(saw_error, "malformed frame should surface an error item");
2070 let terminal = terminal.expect("stream should still emit its terminal record");
2071 assert_eq!(
2072 terminal.finish_reason,
2073 Some(crate::completion::FinishReason::Stop)
2074 );
2075 }
2076
2077 #[tokio::test]
2078 async fn chat_stream_surfaces_recognizable_chunk_with_malformed_field() {
2079 let http_client = MockStreamingClient {
2083 sse_bytes: sse_bytes_from_data_lines([
2084 "{\"object\":\"chat.completion.chunk\",\"choices\":42}",
2085 "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2086 "[DONE]",
2087 ]),
2088 };
2089 let client = Client::builder()
2090 .api_key("copilot-token")
2091 .http_client(http_client)
2092 .build()
2093 .expect("build client");
2094 let model = client.completion_model("gpt-4o");
2095 let request = model.completion_request("hello").build();
2096 let mut stream = model.stream(request).await.expect("stream should start");
2097
2098 let mut saw_error = false;
2099 let mut terminal = None;
2100 while let Some(item) = stream.next().await {
2101 match item {
2102 Ok(StreamedAssistantContent::Final(final_response)) => {
2103 terminal = Some(final_response)
2104 }
2105 Ok(other) => panic!("unexpected stream item: {other:?}"),
2106 Err(err) => {
2107 assert!(
2108 matches!(err, crate::completion::CompletionError::JsonError(_)),
2109 "expected a JSON parse error item, got {err:?}"
2110 );
2111 saw_error = true;
2112 }
2113 }
2114 }
2115
2116 assert!(
2117 saw_error,
2118 "a recognizable chunk with a malformed field should surface an error item"
2119 );
2120 let terminal = terminal.expect("stream should still emit its terminal record");
2121 assert_eq!(
2122 terminal.finish_reason,
2123 Some(crate::completion::FinishReason::Stop)
2124 );
2125 }
2126
2127 #[tokio::test]
2128 async fn chat_stream_skips_unrecognized_event_and_still_completes() {
2129 let http_client = MockStreamingClient {
2134 sse_bytes: sse_bytes_from_data_lines([
2135 "{\"type\":\"copilot.heartbeat\",\"payload\":{}}",
2136 "{\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":null}",
2137 "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2138 "[DONE]",
2139 ]),
2140 };
2141 let client = Client::builder()
2142 .api_key("copilot-token")
2143 .http_client(http_client)
2144 .build()
2145 .expect("build client");
2146 let model = client.completion_model("gpt-4o");
2147 let request = model.completion_request("hello").build();
2148 let mut stream = model.stream(request).await.expect("stream should start");
2149
2150 let mut text = String::new();
2151 let mut terminal = None;
2152 let mut unknown = None;
2153 while let Some(item) = stream.next().await {
2154 match item.expect("unrecognized events must not surface errors") {
2155 StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
2156 StreamedAssistantContent::Final(final_response) => terminal = Some(final_response),
2157 StreamedAssistantContent::Unknown(value) => unknown = Some(value),
2158 other => panic!("unexpected stream item: {other:?}"),
2159 }
2160 }
2161
2162 assert_eq!(text, "hello");
2163 assert_eq!(
2164 unknown,
2165 Some(serde_json::json!({"type": "copilot.heartbeat", "payload": {}}).into()),
2166 "the unrecognized frame must surface verbatim on the raw channel"
2167 );
2168 let terminal = terminal.expect("stream should still emit its terminal record");
2169 assert_eq!(
2170 terminal.finish_reason,
2171 Some(crate::completion::FinishReason::Stop)
2172 );
2173 }
2174
2175 #[tokio::test]
2176 async fn responses_stream_preserves_reasoning_metadata_on_final_response() {
2177 let metadata = serde_json::json!({
2178 "context": "all_turns",
2179 "effort": "ultra",
2180 "summary": null,
2181 "future_control": true
2182 });
2183 let completed = serde_json::json!({
2184 "type": "response.completed",
2185 "sequence_number": 1,
2186 "response": {
2187 "id": "resp_123",
2188 "object": "response",
2189 "created_at": 1700000000,
2190 "status": "completed",
2191 "error": null,
2192 "incomplete_details": null,
2193 "instructions": null,
2194 "max_output_tokens": null,
2195 "model": "gpt-5.3-codex",
2196 "reasoning": metadata.clone(),
2197 "usage": null,
2198 "output": [],
2199 "tools": []
2200 }
2201 });
2202 let http_client = MockStreamingClient {
2203 sse_bytes: sse_bytes_from_json_events(&[completed]),
2204 };
2205 let client = Client::builder()
2206 .api_key("copilot-token")
2207 .http_client(http_client)
2208 .build()
2209 .expect("build client");
2210 let model = client.completion_model("gpt-5.3-codex");
2211 let request = model.completion_request("hello").build();
2212 let mut stream = model
2215 .raw_stream(request)
2216 .await
2217 .expect("stream should start");
2218
2219 while let Some(item) = stream.next().await {
2220 if let crate::streaming::RawStreamingChoice::FinalResponse(
2221 super::CopilotStreamingResponse::Responses(response),
2222 ) = item.expect("completed stream should not error")
2223 {
2224 assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
2225 assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
2226 return;
2227 }
2228 }
2229
2230 panic!("responses stream should yield a final response");
2231 }
2232
2233 #[tokio::test]
2234 async fn chat_stream_terminates_after_transport_error() {
2235 let chunks = vec![
2236 Ok(sse_bytes_from_data_lines([
2237 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2238 ])),
2239 Err(http_client::Error::InvalidStatusCode(
2240 http::StatusCode::BAD_GATEWAY,
2241 )),
2242 ];
2243
2244 let http_client = SequencedStreamingHttpClient::new(chunks);
2245 let client = Client::builder()
2246 .api_key("copilot-token")
2247 .http_client(http_client)
2248 .build()
2249 .expect("build client");
2250 let model = client.completion_model("gpt-4o");
2251 let request = model.completion_request("hello").build();
2252 let mut stream = model.stream(request).await.expect("stream should start");
2253
2254 let mut saw_error = false;
2258 let mut saw_tool_call = false;
2259 while let Some(item) = stream.next().await {
2260 match item {
2261 Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {
2262 assert!(!saw_error, "deltas should precede the terminal error");
2263 }
2264 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
2265 assert!(
2266 !saw_error,
2267 "flushed tool call should precede the terminal error"
2268 );
2269 assert_eq!(tool_call.function.name, "ping");
2270 saw_tool_call = true;
2271 }
2272 Err(err) => {
2273 assert_eq!(
2274 err.to_string(),
2275 "HttpError: Invalid status code: 502 Bad Gateway"
2276 );
2277 assert_eq!(
2278 err.provider_response_status(),
2279 Some(http::StatusCode::BAD_GATEWAY)
2280 );
2281 assert_eq!(err.provider_response_body(), None);
2282 saw_error = true;
2283 }
2284 Ok(other) => panic!("unexpected stream item: {other:?}"),
2285 }
2286 }
2287
2288 assert!(
2289 saw_tool_call,
2290 "fully-delivered tool call should be flushed before the error"
2291 );
2292 assert!(saw_error, "stream should surface the transport error");
2293 assert!(
2294 stream.next().await.is_none(),
2295 "chat stream should end without a terminal record after a transport error"
2296 );
2297 }
2298
2299 #[test]
2300 fn env_api_key_prefers_github_prefixed_vars() {
2301 let env = env_map(&[
2302 ("COPILOT_API_KEY", "copilot-key"),
2303 ("GITHUB_COPILOT_API_KEY", "github-key"),
2304 ("GITHUB_TOKEN", "bootstrap-token"),
2305 ]);
2306 let get = |name: &str| env.get(name).cloned();
2307
2308 assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2309 }
2310
2311 #[test]
2312 fn env_github_access_token_prefers_explicit_bootstrap_var() {
2313 let env = env_map(&[
2314 ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2315 ("GITHUB_TOKEN", "fallback-bootstrap"),
2316 ]);
2317 let get = |name: &str| env.get(name).cloned();
2318
2319 assert_eq!(
2320 env_github_access_token(&get).as_deref(),
2321 Some("explicit-bootstrap")
2322 );
2323 }
2324
2325 #[test]
2326 fn env_base_url_prefers_github_prefixed_vars() {
2327 let env = env_map(&[
2328 ("COPILOT_BASE_URL", "https://copilot.example"),
2329 ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2330 ]);
2331 let get = |name: &str| env.get(name).cloned();
2332
2333 assert_eq!(
2334 env_base_url(&get).as_deref(),
2335 Some("https://github.example")
2336 );
2337 }
2338
2339 #[test]
2340 fn env_without_api_key_falls_back_to_oauth() {
2341 let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2342 let get = |name: &str| env.get(name).cloned();
2343
2344 assert!(env_api_key(&get).is_none());
2345 assert!(env_github_access_token(&get).is_none());
2346 assert_eq!(
2347 env_base_url(&get).as_deref(),
2348 Some("https://copilot.example")
2349 );
2350 }
2351
2352 #[test]
2353 fn env_github_token_is_not_treated_as_copilot_api_key() {
2354 let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2355 let get = |name: &str| env.get(name).cloned();
2356
2357 assert!(env_api_key(&get).is_none());
2358 assert_eq!(
2359 env_github_access_token(&get).as_deref(),
2360 Some("bootstrap-token")
2361 );
2362 }
2363}
2364
2365#[cfg(test)]
2366mod response_identity_tests {
2367 use super::*;
2368
2369 #[test]
2376 fn streaming_terminals_carry_request_id_into_stream_final() {
2377 let mut chat_terminal = openai::completion::streaming::StreamingCompletionResponse::<
2378 openai::completion::Usage,
2379 >::new(openai::completion::Usage::default());
2380 chat_terminal.provider_request_id = Some("req-chat".to_string());
2381 let chat_final: crate::streaming::StreamFinal =
2382 (PROVIDER_NAME, CopilotStreamingResponse::Chat(chat_terminal)).into();
2383 assert_eq!(chat_final.provider_request_id.as_deref(), Some("req-chat"));
2384
2385 let mut responses_terminal = responses_api::streaming::StreamingCompletionResponse::new(
2386 serde_json::from_value(
2387 serde_json::json!({"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}),
2388 )
2389 .expect("usage should parse"),
2390 );
2391 responses_terminal.provider_request_id = Some("req-responses".to_string());
2392 let responses_final: crate::streaming::StreamFinal = (
2393 PROVIDER_NAME,
2394 CopilotStreamingResponse::Responses(responses_terminal),
2395 )
2396 .into();
2397 assert_eq!(
2398 responses_final.provider_request_id.as_deref(),
2399 Some("req-responses")
2400 );
2401 }
2402
2403 #[test]
2408 fn responses_unary_wire_id_survives_normalize() {
2409 use crate::completion::NormalizeCompletionResponse;
2410
2411 let payload = serde_json::json!({
2412 "id": "resp_123",
2413 "object": "response",
2414 "created_at": 0,
2415 "status": "completed",
2416 "model": "gpt-test",
2417 "output": [{
2418 "type": "message",
2419 "id": "msg_1",
2420 "role": "assistant",
2421 "status": "completed",
2422 "content": [{"type": "output_text", "text": "hi", "annotations": []}]
2423 }]
2424 });
2425 let mut response: responses_api::CompletionResponse =
2426 serde_json::from_value(payload).expect("wire response should parse");
2427 response.provider_request_id = Some("req-unary".to_string());
2428
2429 let normalized = response
2430 .normalize(PROVIDER_NAME)
2431 .expect("response should normalize");
2432 assert_eq!(normalized.provider_request_id.as_deref(), Some("req-unary"));
2433 assert_eq!(normalized.response_id.as_deref(), Some("resp_123"));
2434 assert_eq!(normalized.provider, PROVIDER_NAME);
2435 }
2436}
2437
2438#[cfg(test)]
2449mod raw_capture_tests {
2450 use super::*;
2451 use crate::client::CompletionClient;
2452 use crate::completion::CompletionModel as _;
2453 use crate::test_utils::RecordingHttpClient;
2454
2455 const REQUEST_ID: &str = "req_unit_copilot_0001";
2456
2457 const CHAT_BODY: &str = r#"{
2460 "id": "chatcmpl-copilot-raw",
2461 "object": "chat.completion",
2462 "created": 1700000000,
2463 "model": "gpt-4o-2024-11-20",
2464 "system_fingerprint": "fp_copilot_chat",
2465 "choices": [{
2466 "index": 0,
2467 "message": {"role": "assistant", "content": "hello"},
2468 "logprobs": null,
2469 "finish_reason": "stop"
2470 }],
2471 "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}
2472 }"#;
2473
2474 const RESPONSES_BODY: &str = r#"{
2477 "id": "resp_copilot_raw",
2478 "object": "response",
2479 "created_at": 1700000000,
2480 "status": "completed",
2481 "error": null,
2482 "incomplete_details": null,
2483 "instructions": null,
2484 "max_output_tokens": null,
2485 "model": "gpt-5.3-codex",
2486 "service_tier": "default",
2487 "usage": {
2488 "input_tokens": 4,
2489 "input_tokens_details": {"cached_tokens": 0},
2490 "output_tokens": 3,
2491 "output_tokens_details": {"reasoning_tokens": 0},
2492 "total_tokens": 7
2493 },
2494 "output": [{
2495 "type": "message",
2496 "id": "msg_copilot_raw",
2497 "role": "assistant",
2498 "status": "completed",
2499 "content": [{"type": "output_text", "text": "hello", "annotations": []}]
2500 }],
2501 "tools": []
2502 }"#;
2503
2504 fn model(model: &str, body: &'static str) -> CompletionModel<RecordingHttpClient> {
2505 let mut headers = http::HeaderMap::new();
2506 headers.insert("x-request-id", http::HeaderValue::from_static(REQUEST_ID));
2507 let http_client =
2508 RecordingHttpClient::with_error_response_headers(http::StatusCode::OK, body, headers);
2509 let client = Client::builder()
2510 .api_key("copilot-token")
2511 .http_client(http_client)
2512 .build()
2513 .expect("build client");
2514 client.completion_model(model)
2515 }
2516
2517 async fn assert_capture_contract(
2524 model: &CompletionModel<RecordingHttpClient>,
2525 expected_api_tag: &str,
2526 ) -> (completion::CompletionResponse, CopilotCompletionResponse) {
2527 let response = model
2528 .completion(model.completion_request("hello").build())
2529 .await
2530 .expect("completion");
2531 let raw = &response.raw;
2532 assert_eq!(raw["api"], expected_api_tag);
2533 let typed: CopilotCompletionResponse =
2534 serde_json::from_value(raw.clone()).expect("raw must deserialize");
2535 assert_eq!(
2536 serde_json::to_value(&typed).expect("re-serialize"),
2537 *raw,
2538 "the capture must be exactly what the route-tagged raw type serializes to"
2539 );
2540
2541 let renormalized = typed
2542 .clone()
2543 .normalize(PROVIDER_NAME)
2544 .expect("re-normalize the capture")
2545 .with_optional_provider_request_id(Some(REQUEST_ID.to_string()));
2546 assert_eq!(response.identity(), renormalized.identity());
2547 assert_eq!(response.finish_reason(), renormalized.finish_reason());
2548 assert_eq!(response.model, renormalized.model);
2549 assert_eq!(response.usage, renormalized.usage);
2550 assert_eq!(response.choice, renormalized.choice);
2551 assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
2552 (response, typed)
2553 }
2554
2555 async fn assert_parity_contract(model: &CompletionModel<RecordingHttpClient>) {
2560 let (raw, id) = model
2561 .raw_completion_with_request_id(model.completion_request("hello").build())
2562 .await
2563 .expect("typed route");
2564 assert_eq!(id.as_deref(), Some(REQUEST_ID));
2565 let reassembled = raw
2566 .normalize(PROVIDER_NAME)
2567 .expect("normalize")
2568 .with_optional_provider_request_id(id);
2569
2570 let normalized = model
2571 .completion(model.completion_request("hello").build())
2572 .await
2573 .expect("normalized route");
2574
2575 assert_eq!(reassembled.identity(), normalized.identity());
2576 assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
2577 assert_eq!(reassembled.model, normalized.model);
2578 assert_eq!(reassembled.usage, normalized.usage);
2579 assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
2580 assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
2581 assert_eq!(normalized.provider, PROVIDER_NAME);
2582 }
2583
2584 #[tokio::test]
2587 async fn chat_route_raw_round_trips_into_the_route_tagged_type() {
2588 let model = model("gpt-4o", CHAT_BODY);
2589
2590 let (response, typed) = assert_capture_contract(&model, "chat").await;
2591
2592 let CopilotCompletionResponse::Chat(chat) = typed else {
2593 panic!("the chat route must capture the chat variant");
2594 };
2595 assert_eq!(chat.system_fingerprint.as_deref(), Some("fp_copilot_chat"));
2596 assert_eq!(
2597 response.finish_reason(),
2598 Some(completion::FinishReason::Stop)
2599 );
2600 assert_eq!(
2601 response.identity().response_id.as_deref(),
2602 Some("chatcmpl-copilot-raw")
2603 );
2604 }
2605
2606 #[tokio::test]
2609 async fn chat_route_raw_completion_with_request_id_reproduces_completion() {
2610 let model = model("gpt-4o", CHAT_BODY);
2611
2612 assert_parity_contract(&model).await;
2613
2614 let raw = model
2617 .raw_completion(model.completion_request("hello").build())
2618 .await
2619 .expect("typed route");
2620 let normalized = raw.normalize(PROVIDER_NAME).expect("normalize");
2621 assert_eq!(normalized.provider_request_id, None);
2622 }
2623
2624 #[tokio::test]
2630 async fn responses_route_raw_round_trips_into_the_route_tagged_type() {
2631 let model = model("gpt-5.3-codex", RESPONSES_BODY);
2632
2633 let (response, typed) = assert_capture_contract(&model, "responses").await;
2634
2635 let CopilotCompletionResponse::Responses(responses) = typed else {
2636 panic!("the responses route must capture the responses variant");
2637 };
2638 assert!(matches!(
2639 responses.additional_parameters.service_tier,
2640 Some(responses_api::OpenAIServiceTier::Default)
2641 ));
2642 assert_eq!(responses.provider_request_id, None);
2643 assert_eq!(
2644 response.identity().message_id.as_deref(),
2645 Some("msg_copilot_raw")
2646 );
2647 assert_eq!(
2648 response.identity().response_id.as_deref(),
2649 Some("resp_copilot_raw")
2650 );
2651 }
2652
2653 #[tokio::test]
2657 async fn responses_route_raw_completion_with_request_id_reproduces_completion() {
2658 let model = model("gpt-5.3-codex", RESPONSES_BODY);
2659
2660 assert_parity_contract(&model).await;
2661
2662 let (raw, id) = model
2663 .raw_completion_with_request_id(model.completion_request("hello").build())
2664 .await
2665 .expect("typed route");
2666 let CopilotCompletionResponse::Responses(responses) = &raw else {
2667 panic!("codex models route to /responses");
2668 };
2669 assert_eq!(responses.provider_request_id, id);
2670 assert_eq!(id.as_deref(), Some(REQUEST_ID));
2671 }
2672
2673 #[test]
2680 fn copilot_completion_response_round_trips_both_variants() {
2681 let chat: openai::completion::CompletionResponse =
2682 serde_json::from_str(CHAT_BODY).expect("chat body parses");
2683 let responses: responses_api::CompletionResponse =
2684 serde_json::from_str(RESPONSES_BODY).expect("responses body parses");
2685
2686 for (variant, tag) in [
2687 (CopilotCompletionResponse::Chat(Box::new(chat)), "chat"),
2688 (
2689 CopilotCompletionResponse::Responses(Box::new(responses)),
2690 "responses",
2691 ),
2692 ] {
2693 let value = serde_json::to_value(&variant).expect("serialize");
2694 assert_eq!(value["api"], tag);
2695 let back: CopilotCompletionResponse =
2696 serde_json::from_value(value.clone()).expect("deserialize");
2697 assert_eq!(
2698 serde_json::to_value(&back).expect("re-serialize"),
2699 value,
2700 "{tag}: the route-tagged raw type must round-trip"
2701 );
2702 assert_eq!(
2703 back.normalize(PROVIDER_NAME).expect("normalize").provider,
2704 PROVIDER_NAME
2705 );
2706 }
2707 }
2708
2709 #[test]
2714 fn copilot_streaming_response_round_trips_both_variants() {
2715 let mut chat = openai::completion::streaming::StreamingCompletionResponse::<
2716 openai::completion::Usage,
2717 >::new(openai::completion::Usage::default());
2718 chat.finish_reason = Some(completion::FinishReason::Stop);
2719 chat.response_id = Some("chatcmpl-stream".to_string());
2720 chat.model = Some("gpt-4o".to_string());
2721 chat.provider_request_id = Some("req-chat".to_string());
2722 chat.additional_params = Some(
2723 serde_json::from_value(json!({"service_tier": "default"})).expect("additional params"),
2724 );
2725
2726 let mut responses = responses_api::streaming::StreamingCompletionResponse::new(
2727 serde_json::from_value(
2728 json!({"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}),
2729 )
2730 .expect("usage should parse"),
2731 );
2732 responses.provider_request_id = Some("req-responses".to_string());
2733
2734 for (variant, tag) in [
2735 (CopilotStreamingResponse::Chat(chat), "chat"),
2736 (CopilotStreamingResponse::Responses(responses), "responses"),
2737 ] {
2738 let value = serde_json::to_value(&variant).expect("serialize");
2739 assert_eq!(value["api"], tag);
2740 let back: CopilotStreamingResponse =
2741 serde_json::from_value(value.clone()).expect("deserialize");
2742 assert_eq!(
2743 serde_json::to_value(&back).expect("re-serialize"),
2744 value,
2745 "{tag}: the route-tagged terminal must round-trip"
2746 );
2747 let original: crate::streaming::StreamFinal = (PROVIDER_NAME, variant).into();
2748 let restored: crate::streaming::StreamFinal = (PROVIDER_NAME, back).into();
2749 assert_eq!(
2750 restored, original,
2751 "{tag}: normalization must agree across the round-trip"
2752 );
2753 }
2754 }
2755}