1mod auth;
24
25use crate::client::{
26 self, ApiKey, Capabilities, Capable, DebugExt, ModelLister, Nothing, Provider, ProviderBuilder,
27 ProviderClient, Transport,
28};
29use crate::completion::{self, CompletionError, GetTokenUsage};
30use crate::embeddings::{self, EmbeddingError};
31use crate::http_client::{self, HttpClientExt};
32use crate::model::{Model, ModelList, ModelListingError};
33use crate::providers::internal::openai_chat_completions_compatible::{
34 self, CompatibleChoiceData, CompatibleChunk, CompatibleFinishReason, CompatibleStreamProfile,
35 CompatibleToolCallChunk,
36};
37use crate::providers::openai;
38use crate::providers::openai::responses_api::{self, CompletionRequest as ResponsesRequest};
39use crate::streaming::{self, RawStreamingChoice, StreamingCompletionResponse};
40use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
41use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
42use async_stream::stream;
43use futures::StreamExt;
44use http::Request;
45use serde::{Deserialize, Serialize};
46use serde_json::json;
47use std::borrow::Cow;
48use std::collections::HashMap;
49use std::fmt::Debug;
50use std::path::{Path, PathBuf};
51use tracing_futures::Instrument as _;
52
53const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
54pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
55pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
56pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
57const API_VERSION: &str = "2025-04-01";
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum CopilotIntent {
62 #[default]
64 Panel,
65 Edits,
67}
68
69impl CopilotIntent {
70 fn as_header(self) -> &'static str {
71 match self {
72 Self::Panel => "conversation-panel",
73 Self::Edits => "conversation-edits",
74 }
75 }
76}
77
78pub const GPT_4: &str = "gpt-4";
80pub const GPT_4O: &str = "gpt-4o";
82pub const GPT_4O_MINI: &str = "gpt-4o-mini";
84pub const GPT_4_1: &str = "gpt-4.1";
86pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
88pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
90pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
92pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
94pub const GPT_5_5: &str = "gpt-5.5";
96pub const GPT_5_4: &str = "gpt-5.4";
98pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
100pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
102pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
104pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
106pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
108pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
110pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
112pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
114pub const O3_MINI: &str = "o3-mini";
116pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
118pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
120pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
122
123pub use openai::EncodingFormat;
124
125#[derive(Clone)]
126pub enum CopilotAuth {
127 ApiKey(String),
128 GitHubAccessToken(String),
129 OAuth,
130}
131
132impl ApiKey for CopilotAuth {}
133
134impl<S> From<S> for CopilotAuth
135where
136 S: Into<String>,
137{
138 fn from(value: S) -> Self {
139 Self::ApiKey(value.into())
140 }
141}
142
143impl Debug for CopilotAuth {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
147 Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
148 Self::OAuth => f.write_str("OAuth"),
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
154pub struct CopilotBuilder {
155 access_token_file: Option<PathBuf>,
156 api_key_file: Option<PathBuf>,
157 device_code_handler: auth::DeviceCodeHandler,
158 allow_device_flow: bool,
159}
160
161#[derive(Clone)]
162pub struct CopilotExt {
163 auth: auth::Authenticator,
164}
165
166impl Debug for CopilotExt {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("CopilotExt")
169 .field("auth", &self.auth)
170 .finish()
171 }
172}
173
174pub type Client<H = reqwest::Client> = client::Client<CopilotExt, H>;
175pub type ClientBuilder<H = crate::markers::Missing> =
176 client::ClientBuilder<CopilotBuilder, CopilotAuth, H>;
177
178impl Default for CopilotBuilder {
179 fn default() -> Self {
180 let token_dir = default_token_dir();
181 Self {
182 access_token_file: token_dir.as_ref().map(|dir| dir.join("access-token")),
183 api_key_file: token_dir.map(|dir| dir.join("api-key.json")),
184 device_code_handler: auth::DeviceCodeHandler::default(),
185 allow_device_flow: true,
186 }
187 }
188}
189
190impl Provider for CopilotExt {
191 type Builder = CopilotBuilder;
192
193 const VERIFY_PATH: &'static str = "";
194}
195
196impl<H> Capabilities<H> for CopilotExt {
197 type Completion = Capable<CompletionModel<H>>;
198 type Embeddings = Capable<EmbeddingModel<H>>;
199 type Transcription = Nothing;
200 type ModelListing = Capable<CopilotModelLister<H>>;
201 #[cfg(feature = "image")]
202 type ImageGeneration = Nothing;
203 #[cfg(feature = "audio")]
204 type AudioGeneration = Nothing;
205 type Rerank = Nothing;
206}
207
208impl DebugExt for CopilotExt {}
209
210impl ProviderBuilder for CopilotBuilder {
211 type Extension<H>
212 = CopilotExt
213 where
214 H: HttpClientExt;
215 type ApiKey = CopilotAuth;
216
217 const BASE_URL: &'static str = GITHUB_COPILOT_API_BASE_URL;
218
219 fn build<H>(
220 builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
221 ) -> http_client::Result<Self::Extension<H>>
222 where
223 H: HttpClientExt,
224 {
225 let auth = match builder.get_api_key() {
226 CopilotAuth::ApiKey(api_key) => auth::AuthSource::ApiKey(api_key.clone()),
227 CopilotAuth::GitHubAccessToken(access_token) => {
228 auth::AuthSource::GitHubAccessToken(access_token.clone())
229 }
230 CopilotAuth::OAuth => auth::AuthSource::OAuth,
231 };
232
233 let ext = builder.ext();
234 Ok(CopilotExt {
235 auth: auth::Authenticator::new(
236 auth,
237 ext.access_token_file.clone(),
238 ext.api_key_file.clone(),
239 ext.device_code_handler.clone(),
240 ext.allow_device_flow,
241 ),
242 })
243 }
244}
245
246impl ProviderClient for Client {
247 type Input = CopilotAuth;
248 type Error = crate::client::ProviderClientError;
249
250 fn from_env() -> Result<Self, Self::Error> {
251 let mut builder = Self::builder();
252 fn get(name: &str) -> Option<String> {
253 std::env::var(name).ok()
254 }
255
256 if let Some(base_url) = env_base_url(&get) {
257 builder = builder.base_url(base_url);
258 }
259
260 if let Some(api_key) = env_api_key(&get) {
261 builder.api_key(api_key).build().map_err(Into::into)
262 } else if let Some(access_token) = env_github_access_token(&get) {
263 builder
264 .github_access_token(access_token)
265 .build()
266 .map_err(Into::into)
267 } else {
268 builder.oauth().build().map_err(Into::into)
269 }
270 }
271
272 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
273 Self::builder().api_key(input).build().map_err(Into::into)
274 }
275}
276
277impl<H> client::ClientBuilder<CopilotBuilder, crate::markers::Missing, H> {
278 pub fn github_access_token(
279 self,
280 access_token: impl Into<String>,
281 ) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
282 self.api_key(CopilotAuth::GitHubAccessToken(access_token.into()))
283 }
284
285 pub fn oauth(self) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
286 self.api_key(CopilotAuth::OAuth)
287 }
288}
289
290impl<H> ClientBuilder<H> {
291 pub fn on_device_code<F>(self, handler: F) -> Self
292 where
293 F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
294 {
295 self.over_ext(|mut ext| {
296 ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
297 ext
298 })
299 }
300
301 pub fn allow_device_flow(self, allow: bool) -> Self {
308 self.over_ext(|mut ext| {
309 ext.allow_device_flow = allow;
310 ext
311 })
312 }
313
314 pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
315 let path = path.as_ref();
316 self.over_ext(|mut ext| {
317 ext.access_token_file = Some(path.join("access-token"));
318 ext.api_key_file = Some(path.join("api-key.json"));
319 ext
320 })
321 }
322
323 pub fn access_token_file(self, path: impl AsRef<Path>) -> Self {
324 let path = path.as_ref().to_path_buf();
325 self.over_ext(|mut ext| {
326 ext.access_token_file = Some(path);
327 ext
328 })
329 }
330
331 pub fn api_key_file(self, path: impl AsRef<Path>) -> Self {
332 let path = path.as_ref().to_path_buf();
333 self.over_ext(|mut ext| {
334 ext.api_key_file = Some(path);
335 ext
336 })
337 }
338}
339
340fn env_value<F>(get: &F, name: &str) -> Option<String>
341where
342 F: Fn(&str) -> Option<String>,
343{
344 get(name).filter(|value| !value.trim().is_empty())
345}
346
347fn first_env_value<F>(get: &F, keys: &[&str]) -> Option<String>
348where
349 F: Fn(&str) -> Option<String>,
350{
351 keys.iter().find_map(|key| env_value(get, key))
352}
353
354fn env_api_key<F>(get: &F) -> Option<String>
355where
356 F: Fn(&str) -> Option<String>,
357{
358 first_env_value(get, &["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"])
359}
360
361fn env_github_access_token<F>(get: &F) -> Option<String>
362where
363 F: Fn(&str) -> Option<String>,
364{
365 first_env_value(get, &["COPILOT_GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"])
366}
367
368fn env_base_url<F>(get: &F) -> Option<String>
369where
370 F: Fn(&str) -> Option<String>,
371{
372 first_env_value(get, &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"])
373}
374
375impl<H> Client<H>
376where
377 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
378{
379 pub async fn authorize(&self) -> Result<(), auth::AuthError> {
380 self.ext().auth.auth_context().await.map(|_| ())
381 }
382}
383
384fn default_headers(
385 api_key: &str,
386 initiator: &'static str,
387 has_vision: bool,
388 intent: CopilotIntent,
389) -> Vec<(&'static str, String)> {
390 let mut headers = vec![
391 (
392 http::header::AUTHORIZATION.as_str(),
393 format!("Bearer {api_key}"),
394 ),
395 ("copilot-integration-id", "vscode-chat".to_string()),
396 ("editor-version", EDITOR_VERSION.to_string()),
397 ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
398 ("user-agent", USER_AGENT.to_string()),
399 ("openai-intent", intent.as_header().to_string()),
400 ("x-github-api-version", API_VERSION.to_string()),
401 ("x-request-id", crate::id::generate()),
402 (
403 "x-vscode-user-agent-library-version",
404 "electron-fetch".to_string(),
405 ),
406 ("X-Initiator", initiator.to_string()),
407 ];
408
409 if has_vision {
410 headers.push(("copilot-vision-request", "true".to_string()));
411 }
412
413 headers
414}
415
416fn apply_headers(
417 builder: http_client::Builder,
418 headers: &[(&'static str, String)],
419) -> http_client::Builder {
420 headers
421 .iter()
422 .fold(builder, |builder, (key, value)| builder.header(*key, value))
423}
424
425fn runtime_base_url<'a, H>(client: &'a Client<H>, auth: &'a auth::AuthContext) -> Cow<'a, str> {
426 if client.base_url() != GITHUB_COPILOT_API_BASE_URL {
427 return Cow::Borrowed(client.base_url());
428 }
429
430 if let Some(api_base) = auth.api_base.as_deref() {
431 return Cow::Borrowed(api_base);
432 }
433
434 if let Some(base_url) = base_url_from_token(&auth.api_key) {
435 return Cow::Owned(base_url);
436 }
437
438 Cow::Borrowed(client.base_url())
439}
440
441fn base_url_from_token(token: &str) -> Option<String> {
448 let proxy_ep = token
449 .split(';')
450 .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
451 .trim();
452
453 normalize_copilot_proxy_endpoint(proxy_ep)
454}
455
456fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
457 if proxy_ep.is_empty() {
458 return None;
459 }
460
461 let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
462 proxy_ep.to_string()
463 } else {
464 format!("https://{proxy_ep}")
465 };
466
467 let mut url = url::Url::parse(&candidate).ok()?;
468 if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
469 return None;
470 }
471 if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
472 return None;
473 }
474
475 let host = url.host_str()?.to_ascii_lowercase();
476 if !is_allowed_token_derived_copilot_host(&host) {
477 return None;
478 }
479
480 let api_host = host
481 .strip_prefix("proxy.")
482 .map(|suffix| format!("api.{suffix}"))
483 .unwrap_or(host);
484 url.set_host(Some(&api_host)).ok()?;
485
486 Some(url.to_string().trim_end_matches('/').to_string())
487}
488
489fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
490 host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
491}
492
493fn post_with_auth_base<H>(
494 client: &Client<H>,
495 auth: &auth::AuthContext,
496 path: &str,
497 transport: Transport,
498) -> http_client::Result<http_client::Builder> {
499 let uri = client
500 .ext()
501 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
502 let mut req = Request::post(uri);
503
504 if let Some(headers) = req.headers_mut() {
505 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
506 }
507
508 client.ext().with_custom(req)
509}
510
511fn get_with_auth_base<H>(
512 client: &Client<H>,
513 auth: &auth::AuthContext,
514 path: &str,
515 transport: Transport,
516) -> http_client::Result<http_client::Builder> {
517 let uri = client
518 .ext()
519 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
520 let mut req = Request::get(uri);
521
522 if let Some(headers) = req.headers_mut() {
523 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
524 }
525
526 client.ext().with_custom(req)
527}
528
529fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
530 for message in request.chat_history.iter() {
531 match message {
532 crate::completion::Message::Assistant { .. } => return "agent",
533 crate::completion::Message::User { content } => {
534 if content
535 .iter()
536 .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
537 {
538 return "agent";
539 }
540 }
541 crate::completion::Message::System { .. } => {}
542 }
543 }
544
545 "user"
546}
547
548fn request_has_vision(request: &completion::CompletionRequest) -> bool {
549 request.chat_history.iter().any(|message| match message {
550 crate::completion::Message::User { content } => content
551 .iter()
552 .any(|item| matches!(item, crate::message::UserContent::Image(_))),
553 _ => false,
554 })
555}
556
557#[derive(Clone, Copy, Debug, PartialEq, Eq)]
558enum CompletionRoute {
559 ChatCompletions,
560 Responses,
561}
562
563fn route_for_model(model: &str) -> CompletionRoute {
564 if model.to_ascii_lowercase().contains("codex") {
565 CompletionRoute::Responses
566 } else {
567 CompletionRoute::ChatCompletions
568 }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
572#[serde(tag = "api", rename_all = "snake_case")]
573pub enum CopilotCompletionResponse {
574 Chat(Box<ChatCompletionResponse>),
575 Responses(Box<responses_api::CompletionResponse>),
576}
577
578#[derive(Clone, Serialize, Deserialize)]
579#[serde(tag = "api", rename_all = "snake_case")]
580pub enum CopilotStreamingResponse {
581 Chat(openai::completion::streaming::StreamingCompletionResponse),
582 Responses(responses_api::streaming::StreamingCompletionResponse),
583}
584
585impl GetTokenUsage for CopilotStreamingResponse {
586 fn token_usage(&self) -> completion::Usage {
587 match self {
588 Self::Chat(response) => response.token_usage(),
589 Self::Responses(response) => response.token_usage(),
590 }
591 }
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ChatCompletionResponse {
596 pub id: String,
597 #[serde(default)]
598 pub object: Option<String>,
599 #[serde(default)]
600 pub created: Option<u64>,
601 pub model: String,
602 pub system_fingerprint: Option<String>,
603 pub choices: Vec<ChatChoice>,
604 pub usage: Option<openai::completion::Usage>,
605}
606
607#[derive(Clone, Debug, Serialize, Deserialize)]
608pub struct ChatChoice {
609 #[serde(default)]
610 pub index: usize,
611 pub message: openai::completion::Message,
612 pub logprobs: Option<serde_json::Value>,
613 #[serde(default)]
614 pub finish_reason: Option<String>,
615}
616
617impl TryFrom<ChatCompletionResponse> for completion::CompletionResponse<ChatCompletionResponse> {
618 type Error = CompletionError;
619
620 fn try_from(response: ChatCompletionResponse) -> Result<Self, Self::Error> {
621 let choice = response.choices.first().ok_or_else(|| {
622 CompletionError::ResponseError("Response contained no choices".to_owned())
623 })?;
624
625 let content = match &choice.message {
626 openai::completion::Message::Assistant {
627 content,
628 tool_calls,
629 ..
630 } => {
631 let mut content = content
632 .iter()
633 .filter_map(|c| {
634 let s = match c {
635 openai::completion::AssistantContent::Text { text } => text,
636 openai::completion::AssistantContent::Refusal { refusal } => refusal,
637 };
638 if s.is_empty() {
639 None
640 } else {
641 Some(completion::AssistantContent::text(s))
642 }
643 })
644 .collect::<Vec<_>>();
645
646 content.extend(
647 tool_calls
648 .iter()
649 .map(|call| {
650 completion::AssistantContent::tool_call(
651 &call.id,
652 &call.function.name,
653 call.function.arguments.clone(),
654 )
655 })
656 .collect::<Vec<_>>(),
657 );
658 Ok(content)
659 }
660 _ => Err(CompletionError::ResponseError(
661 "Response did not contain a valid message or tool call".into(),
662 )),
663 }?;
664
665 let choice = crate::OneOrMany::many(content).map_err(|_| {
666 CompletionError::ResponseError(
667 "Response contained no message or tool call (empty)".to_owned(),
668 )
669 })?;
670
671 let usage = response
672 .usage
673 .as_ref()
674 .map(|usage| completion::Usage {
675 input_tokens: usage.prompt_tokens as u64,
676 output_tokens: (usage.total_tokens - usage.prompt_tokens) as u64,
677 total_tokens: usage.total_tokens as u64,
678 cached_input_tokens: usage
679 .prompt_tokens_details
680 .as_ref()
681 .map(|d| d.cached_tokens as u64)
682 .unwrap_or(0),
683 cache_creation_input_tokens: 0,
684 tool_use_prompt_tokens: 0,
685 reasoning_tokens: 0,
686 })
687 .unwrap_or_default();
688
689 Ok(completion::CompletionResponse {
690 choice,
691 usage,
692 raw_response: response,
693 message_id: None,
694 })
695 }
696}
697
698#[derive(Debug, Deserialize)]
699pub struct ChatApiErrorResponse {
700 #[serde(default)]
701 pub message: Option<String>,
702 #[serde(default)]
703 pub error: Option<String>,
704}
705
706impl ChatApiErrorResponse {
707 pub fn error_message(&self) -> &str {
708 self.message
709 .as_deref()
710 .or(self.error.as_deref())
711 .unwrap_or("unknown error")
712 }
713}
714
715#[derive(Debug, Deserialize)]
716#[serde(untagged)]
717enum ChatApiResponse<T> {
718 Ok(T),
719 Err(ChatApiErrorResponse),
720}
721
722#[derive(Clone)]
723pub struct CompletionModel<H = reqwest::Client> {
724 client: Client<H>,
725 pub model: String,
726 pub strict_tools: bool,
727 pub tool_result_array_content: bool,
728 pub intent: CopilotIntent,
729}
730
731impl<H> CompletionModel<H>
732where
733 Client<H>: HttpClientExt + Clone + Debug + 'static,
734 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
735{
736 pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
737 Self {
738 client,
739 model: model.into(),
740 strict_tools: false,
741 tool_result_array_content: false,
742 intent: CopilotIntent::default(),
743 }
744 }
745
746 pub fn with_strict_tools(mut self) -> Self {
747 self.strict_tools = true;
748 self
749 }
750
751 pub fn with_tool_result_array_content(mut self) -> Self {
752 self.tool_result_array_content = true;
753 self
754 }
755
756 pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
758 self.intent = intent;
759 self
760 }
761
762 pub fn with_panel_intent(self) -> Self {
764 self.with_intent(CopilotIntent::Panel)
765 }
766
767 pub fn with_edits_intent(self) -> Self {
769 self.with_intent(CopilotIntent::Edits)
770 }
771
772 fn route(&self) -> CompletionRoute {
773 route_for_model(&self.model)
774 }
775
776 async fn auth_context(&self) -> Result<auth::AuthContext, CompletionError> {
777 self.client
778 .ext()
779 .auth
780 .auth_context()
781 .await
782 .map_err(|err| CompletionError::ProviderError(err.to_string()))
783 }
784
785 fn chat_request(
786 &self,
787 completion_request: completion::CompletionRequest,
788 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
789 openai::completion::CompletionRequest::try_from(openai::completion::OpenAIRequestParams {
790 model: self.model.clone(),
791 request: completion_request,
792 strict_tools: self.strict_tools,
793 tool_result_array_content: self.tool_result_array_content,
794 supports_response_format: true,
795 supports_tools: true,
796 })
797 }
798
799 fn responses_request(
800 &self,
801 completion_request: completion::CompletionRequest,
802 ) -> Result<ResponsesRequest, CompletionError> {
803 let mut request = ResponsesRequest::try_from(responses_api::ResponsesRequestParams {
804 model: self.model.clone(),
805 request: completion_request,
806 system_instructions_placement:
807 responses_api::SystemInstructionsPlacement::InputSystemMessages,
808 })?;
809 request.tools = request
813 .tools
814 .into_iter()
815 .map(responses_api::ResponsesToolDefinition::with_strict)
816 .collect();
817 Ok(request)
818 }
819
820 async fn completion_chat(
821 &self,
822 completion_request: completion::CompletionRequest,
823 ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
824 let initiator = request_initiator(&completion_request);
825 let has_vision = request_has_vision(&completion_request);
826 let system_instructions = completion_request.preamble.clone();
827 let record_telemetry_content = completion_request.record_telemetry_content;
828 let request = self.chat_request(completion_request)?;
829 let body = serde_json::to_vec(&request)?;
830 let auth = self.auth_context().await?;
831
832 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
833 let req = apply_headers(
834 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Http)?,
835 &headers,
836 )
837 .body(body)
838 .map_err(|err| CompletionError::HttpError(err.into()))?;
839
840 let span = CompletionSpanBuilder::new("copilot", &request.model, CompletionOperation::Chat)
841 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
842 .build();
843
844 async move {
845 let response = self.client.send(req).await?;
846
847 let status = response.status();
848 if status.is_success() {
849 let body = http_client::text(response).await?;
850 match serde_json::from_str::<ChatApiResponse<ChatCompletionResponse>>(&body)? {
851 ChatApiResponse::Ok(response) => {
852 let core = completion::CompletionResponse::try_from(response.clone())?;
853 let span = tracing::Span::current();
854 span.record("gen_ai.response.id", response.id.as_str());
855 span.record("gen_ai.response.model", response.model.as_str());
856 if let Some(usage) = &response.usage {
857 span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
858 span.record(
859 "gen_ai.usage.output_tokens",
860 usage.total_tokens - usage.prompt_tokens,
861 );
862 span.record(
863 "gen_ai.usage.cache_read.input_tokens",
864 usage
865 .prompt_tokens_details
866 .as_ref()
867 .map(|details| details.cached_tokens)
868 .unwrap_or(0),
869 );
870 }
871
872 Ok(completion::CompletionResponse {
873 choice: core.choice,
874 usage: core.usage,
875 raw_response: CopilotCompletionResponse::Chat(Box::new(response)),
876 message_id: core.message_id,
877 })
878 }
879 ChatApiResponse::Err(err) => {
880 tracing::warn!(
881 message = %err.error_message(),
882 "provider returned an error response"
883 );
884 Err(CompletionError::from_http_response(status, body))
885 }
886 }
887 } else {
888 let body = http_client::text(response).await?;
889 Err(CompletionError::from_http_response(status, body))
890 }
891 }
892 .instrument(span)
893 .await
894 }
895
896 async fn completion_responses(
897 &self,
898 completion_request: completion::CompletionRequest,
899 ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
900 let initiator = request_initiator(&completion_request);
901 let has_vision = request_has_vision(&completion_request);
902 let system_instructions = completion_request.preamble.clone();
903 let record_telemetry_content = completion_request.record_telemetry_content;
904 let request = self.responses_request(completion_request)?;
905 let auth = self.auth_context().await?;
906
907 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
908 let req = apply_headers(
909 post_with_auth_base(&self.client, &auth, "/responses", Transport::Http)?,
910 &headers,
911 )
912 .body(serde_json::to_vec(&request)?)
913 .map_err(|err| CompletionError::HttpError(err.into()))?;
914
915 let span = CompletionSpanBuilder::new("copilot", &request.model, CompletionOperation::Chat)
916 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
917 .build();
918
919 async move {
920 let response = self.client.send(req).await?;
921 let status = response.status();
922 if status.is_success() {
923 let body = http_client::text(response).await?;
924 let response = serde_json::from_str::<responses_api::CompletionResponse>(&body)?;
925 let core = completion::CompletionResponse::try_from(response.clone())?;
926
927 let span = tracing::Span::current();
928 span.record("gen_ai.response.id", response.id.as_str());
929 span.record("gen_ai.response.model", response.model.as_str());
930 if let Some(usage) = &response.usage {
931 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
932 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
933 span.record(
934 "gen_ai.usage.cache_read.input_tokens",
935 usage
936 .input_tokens_details
937 .as_ref()
938 .map(|details| details.cached_tokens)
939 .unwrap_or(0),
940 );
941 }
942
943 Ok(completion::CompletionResponse {
944 choice: core.choice,
945 usage: core.usage,
946 raw_response: CopilotCompletionResponse::Responses(Box::new(response)),
947 message_id: core.message_id,
948 })
949 } else {
950 let body = http_client::text(response).await?;
951 Err(CompletionError::from_http_response(status, body))
952 }
953 }
954 .instrument(span)
955 .await
956 }
957
958 async fn stream_chat(
959 &self,
960 completion_request: completion::CompletionRequest,
961 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
962 let initiator = request_initiator(&completion_request);
963 let has_vision = request_has_vision(&completion_request);
964 let system_instructions = completion_request.preamble.clone();
965 let record_telemetry_content = completion_request.record_telemetry_content;
966 let request = self.chat_request(completion_request)?;
967 let auth = self.auth_context().await?;
968 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
969 let mut request_json = serde_json::to_value(&request)?;
970 let request_object = request_json.as_object_mut().ok_or_else(|| {
971 CompletionError::ResponseError("copilot request body must be a JSON object".into())
972 })?;
973 request_object.insert("stream".to_owned(), json!(true));
974 request_object.insert(
975 "stream_options".to_owned(),
976 json!({ "include_usage": true }),
977 );
978
979 let req = apply_headers(
980 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Sse)?,
981 &headers,
982 )
983 .body(serde_json::to_vec(&request_json)?)
984 .map_err(|err| CompletionError::HttpError(err.into()))?;
985
986 let span = CompletionSpanBuilder::new(
987 "copilot",
988 &request.model,
989 CompletionOperation::ChatStreaming,
990 )
991 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
992 .build();
993
994 tracing::Instrument::instrument(
995 send_copilot_chat_streaming_request(self.client.clone(), req),
996 span,
997 )
998 .await
999 }
1000
1001 async fn stream_responses(
1002 &self,
1003 completion_request: completion::CompletionRequest,
1004 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
1005 let initiator = request_initiator(&completion_request);
1006 let has_vision = request_has_vision(&completion_request);
1007 let system_instructions = completion_request.preamble.clone();
1008 let record_telemetry_content = completion_request.record_telemetry_content;
1009 let mut request = self.responses_request(completion_request)?;
1010 request.stream = Some(true);
1011 let auth = self.auth_context().await?;
1012
1013 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
1014 let req = apply_headers(
1015 post_with_auth_base(&self.client, &auth, "/responses", Transport::Sse)?,
1016 &headers,
1017 )
1018 .body(serde_json::to_vec(&request)?)
1019 .map_err(|err| CompletionError::HttpError(err.into()))?;
1020
1021 let span = CompletionSpanBuilder::new(
1022 "copilot",
1023 &request.model,
1024 CompletionOperation::ChatStreaming,
1025 )
1026 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
1027 .build();
1028
1029 let client = self.client.clone();
1030 let mut event_source = crate::http_client::sse::GenericEventSource::new(client, req);
1031
1032 let stream = tracing_futures::Instrument::instrument(
1033 stream! {
1034 let mut final_usage = responses_api::ResponsesUsage::new();
1035 let mut reasoning_metadata = None;
1036 let mut reasoning_context = None;
1037 let mut tool_calls: Vec<streaming::RawStreamingChoice<CopilotStreamingResponse>> = Vec::new();
1038 let mut tool_call_internal_ids: HashMap<String, String> = HashMap::new();
1039 let span = tracing::Span::current();
1040
1041 let mut terminated_with_error = false;
1042
1043 while let Some(event_result) = event_source.next().await {
1044 match event_result {
1045 Ok(crate::http_client::sse::Event::Open) => continue,
1046 Ok(crate::http_client::sse::Event::Message(evt)) => {
1047 if evt.data.trim().is_empty() {
1048 continue;
1049 }
1050
1051 let Ok(data) = serde_json::from_str::<responses_api::streaming::StreamingCompletionChunk>(&evt.data) else {
1052 continue;
1053 };
1054
1055 if let responses_api::streaming::StreamingCompletionChunk::Delta(chunk) = &data {
1056 use responses_api::streaming::{ItemChunkKind, StreamingItemDoneOutput};
1057
1058 match &chunk.data {
1059 ItemChunkKind::OutputItemAdded(message) => {
1060 if let StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } = message {
1061 let internal_call_id = tool_call_internal_ids
1062 .entry(func.id.clone())
1063 .or_insert_with(crate::id::generate)
1064 .clone();
1065 yield Ok(RawStreamingChoice::ToolCallDelta {
1066 id: func.id.clone(),
1067 internal_call_id,
1068 content: streaming::ToolCallDeltaContent::Name(func.name.clone()),
1069 });
1070 }
1071 }
1072 ItemChunkKind::OutputItemDone(message) => match message {
1073 StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } => {
1074 let internal_id = tool_call_internal_ids
1075 .entry(func.id.clone())
1076 .or_insert_with(crate::id::generate)
1077 .clone();
1078 let raw_tool_call = streaming::RawStreamingToolCall::new(
1079 func.id.clone(),
1080 func.name.clone(),
1081 func.arguments.clone(),
1082 )
1083 .with_internal_call_id(internal_id)
1084 .with_call_id(func.call_id.clone());
1085 tool_calls.push(RawStreamingChoice::ToolCall(raw_tool_call));
1086 }
1087 StreamingItemDoneOutput { item: responses_api::Output::Reasoning { summary, id, content, encrypted_content, .. }, .. } => {
1088 for reasoning_choice in responses_api::streaming::reasoning_choices_from_done_item(
1089 id,
1090 summary,
1091 content,
1092 encrypted_content.as_deref(),
1093 ) {
1094 match reasoning_choice {
1095 RawStreamingChoice::Reasoning { id, content } => {
1096 yield Ok(RawStreamingChoice::Reasoning { id, content });
1097 }
1098 RawStreamingChoice::ReasoningDelta { id, reasoning } => {
1099 yield Ok(RawStreamingChoice::ReasoningDelta { id, reasoning });
1100 }
1101 _ => {}
1102 }
1103 }
1104 }
1105 StreamingItemDoneOutput { item: responses_api::Output::Message(msg), .. } => {
1106 yield Ok(RawStreamingChoice::MessageId(msg.id.clone()));
1107 }
1108 StreamingItemDoneOutput { item: responses_api::Output::Unknown(value), .. } => {
1110 yield Ok(RawStreamingChoice::Unknown(value.clone()));
1111 }
1112 },
1113 ItemChunkKind::OutputTextDelta(delta) => {
1114 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1115 }
1116 ItemChunkKind::ReasoningSummaryTextDelta(delta) => {
1117 yield Ok(RawStreamingChoice::ReasoningDelta { id: None, reasoning: delta.delta.clone() })
1118 }
1119 ItemChunkKind::RefusalDelta(delta) => {
1120 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1121 }
1122 ItemChunkKind::FunctionCallArgsDelta(delta) => {
1123 if let Some(item_id) = chunk.item_id.as_ref() {
1124 let internal_call_id = tool_call_internal_ids
1125 .entry(item_id.clone())
1126 .or_insert_with(crate::id::generate)
1127 .clone();
1128 yield Ok(RawStreamingChoice::ToolCallDelta {
1129 id: item_id.clone(),
1130 internal_call_id,
1131 content: streaming::ToolCallDeltaContent::Delta(delta.delta.clone())
1132 })
1133 }
1134 }
1135 _ => continue,
1136 }
1137 }
1138
1139 if let responses_api::streaming::StreamingCompletionChunk::Response(chunk) = data {
1140 let responses_api::streaming::ResponseChunk { kind, response, .. } = *chunk;
1141 match kind {
1142 responses_api::streaming::ResponseChunkKind::ResponseCompleted => {
1143 span.record("gen_ai.response.id", response.id.as_str());
1144 span.record("gen_ai.response.model", response.model.as_str());
1145 if let Some(usage) = response.usage {
1146 final_usage = usage;
1147 }
1148 if response.reasoning_metadata.is_some() {
1149 reasoning_metadata = response.reasoning_metadata;
1150 }
1151 if response.reasoning_context.is_some() {
1152 reasoning_context = response.reasoning_context;
1153 }
1154 }
1155 responses_api::streaming::ResponseChunkKind::ResponseFailed
1156 | responses_api::streaming::ResponseChunkKind::ResponseIncomplete => {
1157 terminated_with_error = true;
1158 match response.error.as_ref() {
1167 Some(_) => yield Err(
1168 crate::provider_response::completion_error_from_body(
1169 evt.data.clone(),
1170 ),
1171 ),
1172 None => yield Err(CompletionError::ProviderError(
1173 "Copilot response stream failed".into(),
1174 )),
1175 }
1176 break;
1177 }
1178 _ => continue,
1179 }
1180 }
1181 }
1182 Err(crate::http_client::Error::StreamEnded) => {
1183 break;
1184 }
1185 Err(error) => {
1186 terminated_with_error = true;
1187 yield Err(CompletionError::from_stream_transport(error));
1188 break;
1189 }
1190 }
1191 }
1192
1193 event_source.close();
1194
1195 if terminated_with_error {
1196 return;
1197 }
1198
1199 for tool_call in &tool_calls {
1200 yield Ok(tool_call.to_owned())
1201 }
1202
1203 span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1204 span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1205 span.record(
1206 "gen_ai.usage.cache_read.input_tokens",
1207 final_usage
1208 .input_tokens_details
1209 .as_ref()
1210 .map(|details| details.cached_tokens)
1211 .unwrap_or(0),
1212 );
1213
1214 yield Ok(RawStreamingChoice::FinalResponse(
1215 CopilotStreamingResponse::Responses(
1216 responses_api::streaming::StreamingCompletionResponse {
1217 usage: final_usage,
1218 reasoning_metadata,
1219 reasoning_context,
1220 }
1221 )
1222 ));
1223 },
1224 span,
1225 );
1226
1227 Ok(StreamingCompletionResponse::stream(Box::pin(stream)))
1228 }
1229}
1230
1231impl<H> completion::CompletionModel for CompletionModel<H>
1232where
1233 Client<H>: HttpClientExt + Clone + Debug + 'static,
1234 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1235{
1236 type Response = CopilotCompletionResponse;
1237 type StreamingResponse = CopilotStreamingResponse;
1238 type Client = Client<H>;
1239
1240 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1241 Self::new(client.clone(), model)
1242 }
1243
1244 async fn completion(
1245 &self,
1246 completion_request: completion::CompletionRequest,
1247 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1248 match self.route() {
1249 CompletionRoute::ChatCompletions => self.completion_chat(completion_request).await,
1250 CompletionRoute::Responses => self.completion_responses(completion_request).await,
1251 }
1252 }
1253
1254 async fn stream(
1255 &self,
1256 completion_request: completion::CompletionRequest,
1257 ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
1258 match self.route() {
1259 CompletionRoute::ChatCompletions => self.stream_chat(completion_request).await,
1260 CompletionRoute::Responses => self.stream_responses(completion_request).await,
1261 }
1262 }
1263}
1264
1265#[derive(Clone)]
1266pub struct EmbeddingModel<H = reqwest::Client> {
1267 client: Client<H>,
1268 pub model: String,
1269 pub encoding_format: Option<openai::EncodingFormat>,
1270 pub user: Option<String>,
1271 ndims: usize,
1272}
1273
1274#[derive(Deserialize)]
1275struct CopilotEmbeddingResponse {
1276 data: Vec<CopilotEmbeddingData>,
1277}
1278
1279#[derive(Deserialize)]
1280struct CopilotEmbeddingData {
1281 embedding: Vec<serde_json::Number>,
1282}
1283
1284impl<H> EmbeddingModel<H>
1285where
1286 Client<H>: HttpClientExt + Clone + Debug + 'static,
1287 H: Clone + Default + Debug + 'static,
1288{
1289 pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1290 Self {
1291 client,
1292 model: model.into(),
1293 encoding_format: None,
1294 user: None,
1295 ndims,
1296 }
1297 }
1298}
1299
1300impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1301where
1302 Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1303 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1304{
1305 const MAX_DOCUMENTS: usize = 1024;
1306 type Client = Client<H>;
1307
1308 fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1309 let model = model.into();
1310 let dims = ndims.unwrap_or(match model.as_str() {
1311 TEXT_EMBEDDING_3_LARGE => 3072,
1312 TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1313 _ => 0,
1314 });
1315 Self::new(client.clone(), model, dims)
1316 }
1317
1318 fn ndims(&self) -> usize {
1319 self.ndims
1320 }
1321
1322 async fn embed_texts(
1323 &self,
1324 documents: impl IntoIterator<Item = String>,
1325 ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1326 let documents = documents.into_iter().collect::<Vec<_>>();
1327 let auth = self
1328 .client
1329 .ext()
1330 .auth
1331 .auth_context()
1332 .await
1333 .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1334
1335 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1336 let mut body = json!({
1337 "model": self.model,
1338 "input": documents,
1339 });
1340
1341 let body_object = body.as_object_mut().ok_or_else(|| {
1342 EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1343 })?;
1344
1345 if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1346 body_object.insert("dimensions".to_owned(), json!(self.ndims));
1347 }
1348 if let Some(encoding_format) = &self.encoding_format {
1349 body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1350 }
1351 if let Some(user) = &self.user {
1352 body_object.insert("user".to_owned(), json!(user));
1353 }
1354
1355 let req = apply_headers(
1356 post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1357 &headers,
1358 )
1359 .body(serde_json::to_vec(&body)?)
1360 .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1361
1362 let response = self.client.send(req).await?;
1363 let status = response.status();
1364 if status.is_success() {
1365 let body: Vec<u8> = response.into_body().await?;
1366 #[derive(Deserialize)]
1367 struct NestedApiError {
1368 error: NestedApiErrorMessage,
1369 }
1370
1371 #[derive(Deserialize)]
1372 struct NestedApiErrorMessage {
1373 message: String,
1374 }
1375
1376 let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1377 Ok(parsed) => parsed,
1378 Err(parse_error) => {
1379 if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1380 tracing::warn!(message = %err.error.message, "provider returned an error response");
1381 return Err(EmbeddingError::from_http_response(
1382 status,
1383 String::from_utf8_lossy(&body).into_owned(),
1384 ));
1385 }
1386
1387 let preview = String::from_utf8_lossy(&body);
1388 let preview = if preview.len() > 512 {
1389 format!("{}...", &preview[..512])
1390 } else {
1391 preview.into_owned()
1392 };
1393
1394 return Err(EmbeddingError::ProviderError(format!(
1395 "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1396 )));
1397 }
1398 };
1399
1400 Ok(body
1401 .data
1402 .into_iter()
1403 .zip(documents.into_iter())
1404 .map(|(embedding, document)| embeddings::Embedding {
1405 document,
1406 vec: embedding
1407 .embedding
1408 .into_iter()
1409 .filter_map(|n| n.as_f64())
1410 .collect(),
1411 })
1412 .collect())
1413 } else {
1414 let text = http_client::text(response).await?;
1415 Err(EmbeddingError::from_http_response(status, text))
1416 }
1417 }
1418}
1419
1420const MODEL_LISTING_PATH: &str = "/models";
1421const MODEL_LISTING_PROVIDER: &str = "Copilot";
1422
1423#[derive(Debug, Deserialize)]
1424struct ListModelsResponse {
1425 data: Vec<ListModelEntry>,
1426}
1427
1428#[derive(Debug, Deserialize)]
1429struct ListModelEntry {
1430 id: String,
1431 #[serde(default)]
1432 name: Option<String>,
1433 #[serde(default)]
1434 vendor: Option<String>,
1435 #[serde(default)]
1436 capabilities: Option<ListModelEntryCapabilities>,
1437}
1438
1439#[derive(Debug, Deserialize)]
1440struct ListModelEntryCapabilities {
1441 #[serde(default, rename = "type")]
1442 r#type: Option<String>,
1443}
1444
1445impl From<ListModelEntry> for Model {
1446 fn from(value: ListModelEntry) -> Self {
1447 let mut model = Model::from_id(value.id);
1448 model.name = value.name;
1449 model.owned_by = value.vendor;
1450 if let Some(caps) = value.capabilities {
1451 model.r#type = caps.r#type;
1452 }
1453 model
1454 }
1455}
1456
1457#[derive(Clone)]
1459pub struct CopilotModelLister<H = reqwest::Client> {
1460 client: Client<H>,
1461}
1462
1463impl<H> ModelLister<H> for CopilotModelLister<H>
1464where
1465 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1466{
1467 type Client = Client<H>;
1468
1469 fn new(client: Self::Client) -> Self {
1470 Self { client }
1471 }
1472
1473 async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1474 let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1475 ModelListingError::AuthError {
1476 message: err.to_string(),
1477 }
1478 })?;
1479
1480 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1481 let req = apply_headers(
1482 get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1483 &headers,
1484 )
1485 .body(http_client::NoBody)?;
1486
1487 let response = self.client.send::<_, Vec<u8>>(req).await?;
1488
1489 if !response.status().is_success() {
1490 let status_code = response.status().as_u16();
1491 let body = response.into_body().await?;
1492 return Err(ModelListingError::api_error_with_context(
1493 MODEL_LISTING_PROVIDER,
1494 MODEL_LISTING_PATH,
1495 status_code,
1496 &body,
1497 ));
1498 }
1499
1500 let body = response.into_body().await?;
1501 let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
1502 ModelListingError::parse_error_with_context(
1503 MODEL_LISTING_PROVIDER,
1504 MODEL_LISTING_PATH,
1505 &error,
1506 &body,
1507 )
1508 })?;
1509 let models = api_resp.data.into_iter().map(Model::from).collect();
1510
1511 Ok(ModelList::new(models))
1512 }
1513}
1514
1515#[derive(Deserialize, Debug)]
1516struct ChatStreamingFunction {
1517 name: Option<String>,
1518 arguments: Option<String>,
1519}
1520
1521#[derive(Deserialize, Debug)]
1522struct ChatStreamingToolCall {
1523 index: usize,
1524 id: Option<String>,
1525 function: ChatStreamingFunction,
1526}
1527
1528impl From<&ChatStreamingToolCall> for CompatibleToolCallChunk {
1529 fn from(value: &ChatStreamingToolCall) -> Self {
1530 Self {
1531 index: value.index,
1532 id: value.id.clone(),
1533 name: value.function.name.clone(),
1534 arguments: value.function.arguments.clone(),
1535 }
1536 }
1537}
1538
1539#[derive(Deserialize, Debug, Default)]
1540struct ChatStreamingDelta {
1541 #[serde(default)]
1542 content: Option<String>,
1543 #[serde(default)]
1544 reasoning_content: Option<String>,
1545 #[serde(default, deserialize_with = "crate::json_utils::null_or_vec")]
1546 tool_calls: Vec<ChatStreamingToolCall>,
1547}
1548
1549#[derive(Deserialize, Debug, PartialEq)]
1550#[serde(rename_all = "snake_case")]
1551enum ChatFinishReason {
1552 ToolCalls,
1553 Stop,
1554 ContentFilter,
1555 Length,
1556 #[serde(untagged)]
1557 Other(String),
1558}
1559
1560#[derive(Deserialize, Debug)]
1561struct ChatStreamingChoice {
1562 delta: ChatStreamingDelta,
1563 finish_reason: Option<ChatFinishReason>,
1564}
1565
1566#[derive(Deserialize, Debug)]
1567struct ChatStreamingChunk {
1568 id: Option<String>,
1569 model: Option<String>,
1570 choices: Vec<ChatStreamingChoice>,
1571 usage: Option<openai::completion::Usage>,
1572}
1573
1574#[derive(Clone, Copy)]
1575struct CopilotChatCompatibleProfile;
1576
1577impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
1578 type Usage = openai::completion::Usage;
1579 type Detail = ();
1580 type FinalResponse = CopilotStreamingResponse;
1581
1582 fn normalize_chunk(
1583 &self,
1584 data: &str,
1585 ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
1586 let data = match serde_json::from_str::<ChatStreamingChunk>(data) {
1587 Ok(data) => data,
1588 Err(error) => {
1589 tracing::debug!(?error, "Couldn't parse Copilot chat SSE payload");
1590 return Ok(None);
1591 }
1592 };
1593
1594 Ok(Some(
1595 openai_chat_completions_compatible::normalize_first_choice_chunk(
1596 data.id,
1597 data.model,
1598 data.usage,
1599 &data.choices,
1600 |choice| CompatibleChoiceData {
1601 finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
1602 CompatibleFinishReason::ToolCalls
1603 } else {
1604 CompatibleFinishReason::Other
1605 },
1606 text: choice.delta.content.clone(),
1607 reasoning: choice.delta.reasoning_content.clone(),
1608 tool_calls: openai_chat_completions_compatible::tool_call_chunks(
1609 &choice.delta.tool_calls,
1610 ),
1611 details: Vec::new(),
1612 },
1613 ),
1614 ))
1615 }
1616
1617 fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
1618 CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
1619 usage,
1620 })
1621 }
1622
1623 fn uses_distinct_tool_call_eviction(&self) -> bool {
1624 true
1625 }
1626}
1627
1628async fn send_copilot_chat_streaming_request<T>(
1629 http_client: T,
1630 req: Request<Vec<u8>>,
1631) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError>
1632where
1633 T: HttpClientExt + Clone + 'static,
1634{
1635 openai_chat_completions_compatible::send_compatible_streaming_request(
1636 http_client,
1637 req,
1638 CopilotChatCompatibleProfile,
1639 )
1640 .await
1641}
1642
1643fn default_token_dir() -> Option<PathBuf> {
1644 config_dir().map(|dir| dir.join("github_copilot"))
1645}
1646
1647fn config_dir() -> Option<PathBuf> {
1648 #[cfg(target_os = "windows")]
1649 {
1650 std::env::var_os("APPDATA").map(PathBuf::from)
1651 }
1652
1653 #[cfg(not(target_os = "windows"))]
1654 {
1655 std::env::var_os("XDG_CONFIG_HOME")
1656 .map(PathBuf::from)
1657 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1658 }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::{
1664 ChatApiErrorResponse, ChatCompletionResponse, Client, CompletionRoute, CopilotIntent,
1665 TEXT_EMBEDDING_3_SMALL, base_url_from_token, default_headers, env_api_key, env_base_url,
1666 env_github_access_token, route_for_model,
1667 };
1668 use crate::client::CompletionClient;
1669 use crate::completion::CompletionModel;
1670 use crate::http_client;
1671 use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1672 sse_bytes_from_data_lines, sse_bytes_from_json_events,
1673 };
1674 use crate::streaming::StreamedAssistantContent;
1675 use crate::test_utils::MockStreamingClient;
1676 use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1677 use futures::StreamExt;
1678 use std::collections::HashMap;
1679
1680 fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1681 entries
1682 .iter()
1683 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1684 .collect()
1685 }
1686
1687 fn minimal_chat_response() -> &'static str {
1688 r#"{
1689 "id": "chatcmpl-123",
1690 "model": "gpt-4o",
1691 "choices": [{
1692 "index": 0,
1693 "message": {
1694 "role": "assistant",
1695 "content": "hello"
1696 },
1697 "finish_reason": "stop"
1698 }],
1699 "usage": {
1700 "prompt_tokens": 4,
1701 "total_tokens": 7
1702 }
1703 }"#
1704 }
1705
1706 fn minimal_responses_response() -> &'static str {
1707 r#"{
1708 "id": "resp_123",
1709 "object": "response",
1710 "created_at": 1700000000,
1711 "status": "completed",
1712 "error": null,
1713 "incomplete_details": null,
1714 "instructions": null,
1715 "max_output_tokens": null,
1716 "model": "gpt-5.3-codex",
1717 "usage": {
1718 "input_tokens": 4,
1719 "input_tokens_details": {
1720 "cached_tokens": 0
1721 },
1722 "output_tokens": 3,
1723 "output_tokens_details": {
1724 "reasoning_tokens": 0
1725 },
1726 "total_tokens": 7
1727 },
1728 "output": [{
1729 "type": "message",
1730 "id": "msg_123",
1731 "role": "assistant",
1732 "status": "completed",
1733 "content": [{
1734 "type": "output_text",
1735 "text": "hello"
1736 }]
1737 }],
1738 "tools": []
1739 }"#
1740 }
1741
1742 fn minimal_embeddings_response() -> &'static str {
1743 r#"{
1744 "data": [
1745 {
1746 "embedding": [0.1, 0.2, 0.3]
1747 },
1748 {
1749 "embedding": [0.4, 0.5, 0.6]
1750 }
1751 ]
1752 }"#
1753 }
1754
1755 #[test]
1756 fn deserialize_standard_openai_response() {
1757 let json = r#"{
1758 "id": "chatcmpl-abc123",
1759 "object": "chat.completion",
1760 "created": 1700000000,
1761 "model": "gpt-4o",
1762 "choices": [{
1763 "index": 0,
1764 "message": {
1765 "role": "assistant",
1766 "content": "Hello!"
1767 },
1768 "finish_reason": "stop"
1769 }],
1770 "usage": {
1771 "prompt_tokens": 10,
1772 "completion_tokens": 5,
1773 "total_tokens": 15
1774 }
1775 }"#;
1776
1777 let response: ChatCompletionResponse =
1778 serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1779 assert_eq!(response.id, "chatcmpl-abc123");
1780 assert_eq!(response.object.as_deref(), Some("chat.completion"));
1781 assert_eq!(response.created, Some(1700000000));
1782 assert_eq!(response.model, "gpt-4o");
1783 assert_eq!(response.choices.len(), 1);
1784 assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
1785 }
1786
1787 #[test]
1788 fn deserialize_copilot_response_without_object_and_created() {
1789 let response: ChatCompletionResponse = serde_json::from_str(minimal_chat_response())
1790 .expect("Copilot response should deserialize");
1791
1792 assert_eq!(response.id, "chatcmpl-123");
1793 assert_eq!(response.object, None);
1794 assert_eq!(response.created, None);
1795 assert_eq!(response.model, "gpt-4o");
1796 assert_eq!(response.choices.len(), 1);
1797 }
1798
1799 #[test]
1800 fn deserialize_copilot_response_without_finish_reason() {
1801 let json = r#"{
1802 "id": "chatcmpl-claude-001",
1803 "model": "claude-3.5-sonnet",
1804 "choices": [{
1805 "message": {
1806 "role": "assistant",
1807 "content": "Here is my analysis."
1808 }
1809 }],
1810 "usage": {
1811 "prompt_tokens": 50,
1812 "total_tokens": 80
1813 }
1814 }"#;
1815
1816 let response: ChatCompletionResponse =
1817 serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1818
1819 assert_eq!(response.model, "claude-3.5-sonnet");
1820 assert_eq!(response.choices[0].finish_reason, None);
1821 assert_eq!(response.choices[0].index, 0);
1822 }
1823
1824 #[test]
1825 fn error_response_with_message_field() {
1826 let json = r#"{"message": "rate limit exceeded"}"#;
1827 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1828
1829 assert_eq!(err.error_message(), "rate limit exceeded");
1830 }
1831
1832 #[test]
1833 fn error_response_with_error_field() {
1834 let json = r#"{"error": "model not found"}"#;
1835 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1836
1837 assert_eq!(err.error_message(), "model not found");
1838 }
1839
1840 #[test]
1841 fn routes_codex_models_to_responses() {
1842 assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1843 assert_eq!(
1844 route_for_model("gpt-5.1-CODEX-mini"),
1845 CompletionRoute::Responses
1846 );
1847 assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1848 assert_eq!(
1849 route_for_model("claude-sonnet-4.5"),
1850 CompletionRoute::ChatCompletions
1851 );
1852 }
1853
1854 #[test]
1855 fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1856 let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1857 assert_eq!(
1858 panel_headers
1859 .iter()
1860 .find(|(name, _)| *name == "openai-intent")
1861 .map(|(_, value)| value.as_str()),
1862 Some("conversation-panel")
1863 );
1864
1865 let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1866 assert_eq!(
1867 edits_headers
1868 .iter()
1869 .find(|(name, _)| *name == "openai-intent")
1870 .map(|(_, value)| value.as_str()),
1871 Some("conversation-edits")
1872 );
1873 }
1874
1875 #[test]
1876 fn copilot_completion_model_intent_builders_update_intent() {
1877 let client = Client::builder()
1878 .api_key("copilot-token")
1879 .build()
1880 .expect("build client");
1881
1882 let default_model = client.completion_model("gpt-4o");
1883 assert_eq!(default_model.intent.as_header(), "conversation-panel");
1884
1885 let edits_model = client
1886 .completion_model("gpt-4o")
1887 .with_intent(CopilotIntent::Edits);
1888 assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1889
1890 let panel_model = client
1891 .completion_model("gpt-4o")
1892 .with_edits_intent()
1893 .with_panel_intent();
1894 assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1895 }
1896
1897 #[test]
1898 fn base_url_from_token_derives_api_endpoint() {
1899 assert_eq!(
1900 base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1901 .as_deref(),
1902 Some("https://api.individual.githubcopilot.com")
1903 );
1904 assert_eq!(
1905 base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1906 .as_deref(),
1907 Some("https://api.individual.githubcopilot.com")
1908 );
1909 assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1910 }
1911
1912 #[test]
1913 fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1914 assert_eq!(
1915 base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1916 None
1917 );
1918 assert_eq!(
1919 base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1920 None
1921 );
1922 assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1923 assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1924 assert_eq!(
1925 base_url_from_token(
1926 "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1927 ),
1928 None
1929 );
1930 }
1931
1932 #[tokio::test]
1933 async fn api_key_with_proxy_endpoint_overrides_base_url() {
1934 let http_client = RecordingHttpClient::new(minimal_chat_response());
1935 let client = Client::builder()
1936 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1937 .http_client(http_client.clone())
1938 .build()
1939 .expect("build client");
1940 let model = client.completion_model("gpt-4o");
1941 let request = model.completion_request("hello").build();
1942
1943 let _response = model.completion(request).await.expect("chat completion");
1944
1945 let requests = http_client.requests();
1946 assert_eq!(requests.len(), 1);
1947 assert!(
1948 requests[0]
1949 .uri
1950 .starts_with("https://api.individual.githubcopilot.com"),
1951 "expected proxy-derived base URL, got {}",
1952 requests[0].uri
1953 );
1954 }
1955
1956 #[tokio::test]
1957 async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1958 let http_client = RecordingHttpClient::new(minimal_chat_response());
1959 let client = Client::builder()
1960 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1961 .base_url("https://custom.example.com")
1962 .http_client(http_client.clone())
1963 .build()
1964 .expect("build client");
1965 let model = client.completion_model("gpt-4o");
1966 let request = model.completion_request("hello").build();
1967
1968 let _response = model.completion(request).await.expect("chat completion");
1969
1970 let requests = http_client.requests();
1971 assert_eq!(requests.len(), 1);
1972 assert!(
1973 requests[0].uri.starts_with("https://custom.example.com"),
1974 "expected explicit base URL, got {}",
1975 requests[0].uri
1976 );
1977 }
1978
1979 #[tokio::test]
1980 async fn completion_model_edits_intent_sets_request_header() {
1981 let http_client = RecordingHttpClient::new(minimal_chat_response());
1982 let client = Client::builder()
1983 .api_key("copilot-token")
1984 .http_client(http_client.clone())
1985 .build()
1986 .expect("build client");
1987 let model = client.completion_model("gpt-4o").with_edits_intent();
1988 let request = model.completion_request("hello").build();
1989
1990 let _response = model.completion(request).await.expect("chat completion");
1991
1992 let requests = http_client.requests();
1993 assert_eq!(requests.len(), 1);
1994 assert_eq!(
1995 requests[0]
1996 .headers
1997 .get("openai-intent")
1998 .and_then(|value| value.to_str().ok()),
1999 Some("conversation-edits")
2000 );
2001 }
2002
2003 #[tokio::test]
2004 async fn completion_model_routes_chat_requests_to_chat_completions() {
2005 let http_client = RecordingHttpClient::new(minimal_chat_response());
2006 let client = Client::builder()
2007 .api_key("copilot-token")
2008 .http_client(http_client.clone())
2009 .build()
2010 .expect("build client");
2011 let model = client.completion_model("gpt-4o");
2012 let request = model.completion_request("hello").build();
2013
2014 let _response = model.completion(request).await.expect("chat completion");
2015
2016 let requests = http_client.requests();
2017 assert_eq!(requests.len(), 1);
2018 assert!(requests[0].uri.ends_with("/chat/completions"));
2019 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
2020 }
2021
2022 #[tokio::test]
2023 async fn completion_model_routes_codex_requests_to_responses() {
2024 let http_client = RecordingHttpClient::new(minimal_responses_response());
2025 let client = Client::builder()
2026 .api_key("copilot-token")
2027 .http_client(http_client.clone())
2028 .build()
2029 .expect("build client");
2030 let model = client.completion_model("gpt-5.3-codex");
2031 let request = model.completion_request("hello").build();
2032
2033 let _response = model
2034 .completion(request)
2035 .await
2036 .expect("responses completion");
2037
2038 let requests = http_client.requests();
2039 assert_eq!(requests.len(), 1);
2040 assert!(requests[0].uri.ends_with("/responses"));
2041 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
2042 }
2043
2044 #[tokio::test]
2045 async fn embeddings_accept_minimal_copilot_response_shape() {
2046 use crate::client::EmbeddingsClient;
2047 use crate::embeddings::EmbeddingModel as _;
2048
2049 let http_client = RecordingHttpClient::new(minimal_embeddings_response());
2050 let client = Client::builder()
2051 .api_key("copilot-token")
2052 .http_client(http_client.clone())
2053 .build()
2054 .expect("build client");
2055 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
2056
2057 let embeddings = model
2058 .embed_texts(["one".to_string(), "two".to_string()])
2059 .await
2060 .expect("embeddings should deserialize");
2061
2062 assert_eq!(embeddings.len(), 2);
2063 assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
2064 assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
2065
2066 let requests = http_client.requests();
2067 assert_eq!(requests.len(), 1);
2068 assert!(requests[0].uri.ends_with("/embeddings"));
2069 assert!(
2070 String::from_utf8_lossy(&requests[0].body)
2071 .contains("\"model\":\"text-embedding-3-small\"")
2072 );
2073 }
2074
2075 #[tokio::test]
2076 async fn responses_stream_terminates_after_terminal_error() {
2077 let tool_call_done = serde_json::json!({
2078 "type": "response.output_item.done",
2079 "sequence_number": 1,
2080 "item": {
2081 "type": "function_call",
2082 "id": "fc_123",
2083 "arguments": "{}",
2084 "call_id": "call_123",
2085 "name": "example_tool",
2086 "status": "completed"
2087 }
2088 });
2089 let failed = serde_json::json!({
2090 "type": "response.failed",
2091 "sequence_number": 2,
2092 "response": {
2093 "id": "resp_123",
2094 "object": "response",
2095 "created_at": 1700000000,
2096 "status": "failed",
2097 "error": {
2098 "code": "server_error",
2099 "message": "Copilot response stream failed"
2100 },
2101 "incomplete_details": null,
2102 "instructions": null,
2103 "max_output_tokens": null,
2104 "model": "gpt-5.3-codex",
2105 "usage": null,
2106 "output": [],
2107 "tools": []
2108 }
2109 });
2110 let http_client = MockStreamingClient {
2111 sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2112 };
2113 let client = Client::builder()
2114 .api_key("copilot-token")
2115 .http_client(http_client)
2116 .build()
2117 .expect("build client");
2118 let model = client.completion_model("gpt-5.3-codex");
2119 let request = model.completion_request("hello").build();
2120 let mut stream = model.stream(request).await.expect("stream should start");
2121
2122 let err = match stream.next().await.expect("stream should yield an item") {
2123 Ok(_) => panic!("stream should surface a provider error"),
2124 Err(err) => err,
2125 };
2126 assert!(matches!(
2131 err,
2132 crate::completion::CompletionError::ProviderResponse(_)
2133 ));
2134 assert_eq!(err.provider_response_status(), None);
2135 let json = err
2136 .provider_response_json()
2137 .expect("preserved body should parse as JSON")
2138 .expect("preserved body should not be empty");
2139 let response_error = json
2140 .get("response")
2141 .and_then(|response| response.get("error"))
2142 .expect("preserved body should retain the provider error object");
2143 assert_eq!(
2144 response_error.get("code").and_then(|value| value.as_str()),
2145 Some("server_error")
2146 );
2147 assert_eq!(
2148 response_error
2149 .get("message")
2150 .and_then(|value| value.as_str()),
2151 Some("Copilot response stream failed")
2152 );
2153 assert!(
2154 stream.next().await.is_none(),
2155 "responses stream should terminate immediately after a terminal error"
2156 );
2157 }
2158
2159 #[tokio::test]
2160 async fn responses_stream_preserves_reasoning_metadata_on_final_response() {
2161 let metadata = serde_json::json!({
2162 "context": "all_turns",
2163 "effort": "ultra",
2164 "summary": null,
2165 "future_control": true
2166 });
2167 let completed = serde_json::json!({
2168 "type": "response.completed",
2169 "sequence_number": 1,
2170 "response": {
2171 "id": "resp_123",
2172 "object": "response",
2173 "created_at": 1700000000,
2174 "status": "completed",
2175 "error": null,
2176 "incomplete_details": null,
2177 "instructions": null,
2178 "max_output_tokens": null,
2179 "model": "gpt-5.3-codex",
2180 "reasoning": metadata.clone(),
2181 "usage": null,
2182 "output": [],
2183 "tools": []
2184 }
2185 });
2186 let http_client = MockStreamingClient {
2187 sse_bytes: sse_bytes_from_json_events(&[completed]),
2188 };
2189 let client = Client::builder()
2190 .api_key("copilot-token")
2191 .http_client(http_client)
2192 .build()
2193 .expect("build client");
2194 let model = client.completion_model("gpt-5.3-codex");
2195 let request = model.completion_request("hello").build();
2196 let mut stream = model.stream(request).await.expect("stream should start");
2197
2198 while let Some(item) = stream.next().await {
2199 if let StreamedAssistantContent::Final(super::CopilotStreamingResponse::Responses(
2200 response,
2201 )) = item.expect("completed stream should not error")
2202 {
2203 assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
2204 assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
2205 return;
2206 }
2207 }
2208
2209 panic!("responses stream should yield a final response");
2210 }
2211
2212 #[tokio::test]
2213 async fn chat_stream_terminates_after_transport_error() {
2214 let chunks = vec![
2215 Ok(sse_bytes_from_data_lines([
2216 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2217 ])),
2218 Err(http_client::Error::InvalidStatusCode(
2219 http::StatusCode::BAD_GATEWAY,
2220 )),
2221 ];
2222
2223 let http_client = SequencedStreamingHttpClient::new(chunks);
2224 let client = Client::builder()
2225 .api_key("copilot-token")
2226 .http_client(http_client)
2227 .build()
2228 .expect("build client");
2229 let model = client.completion_model("gpt-4o");
2230 let request = model.completion_request("hello").build();
2231 let mut stream = model.stream(request).await.expect("stream should start");
2232
2233 let mut saw_error = false;
2234 while let Some(item) = stream.next().await {
2235 match item {
2236 Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {}
2237 Err(err) => {
2238 assert_eq!(
2239 err.to_string(),
2240 "HttpError: Invalid status code: 502 Bad Gateway"
2241 );
2242 assert_eq!(
2243 err.provider_response_status(),
2244 Some(http::StatusCode::BAD_GATEWAY)
2245 );
2246 assert_eq!(err.provider_response_body(), None);
2247 saw_error = true;
2248 break;
2249 }
2250 Ok(_) => panic!("unexpected non-error stream item before transport failure"),
2251 }
2252 }
2253
2254 assert!(saw_error, "stream should surface the transport error");
2255 assert!(
2256 stream.next().await.is_none(),
2257 "chat stream should terminate immediately after a transport error"
2258 );
2259 }
2260
2261 #[test]
2262 fn env_api_key_prefers_github_prefixed_vars() {
2263 let env = env_map(&[
2264 ("COPILOT_API_KEY", "copilot-key"),
2265 ("GITHUB_COPILOT_API_KEY", "github-key"),
2266 ("GITHUB_TOKEN", "bootstrap-token"),
2267 ]);
2268 let get = |name: &str| env.get(name).cloned();
2269
2270 assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2271 }
2272
2273 #[test]
2274 fn env_github_access_token_prefers_explicit_bootstrap_var() {
2275 let env = env_map(&[
2276 ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2277 ("GITHUB_TOKEN", "fallback-bootstrap"),
2278 ]);
2279 let get = |name: &str| env.get(name).cloned();
2280
2281 assert_eq!(
2282 env_github_access_token(&get).as_deref(),
2283 Some("explicit-bootstrap")
2284 );
2285 }
2286
2287 #[test]
2288 fn env_base_url_prefers_github_prefixed_vars() {
2289 let env = env_map(&[
2290 ("COPILOT_BASE_URL", "https://copilot.example"),
2291 ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2292 ]);
2293 let get = |name: &str| env.get(name).cloned();
2294
2295 assert_eq!(
2296 env_base_url(&get).as_deref(),
2297 Some("https://github.example")
2298 );
2299 }
2300
2301 #[test]
2302 fn env_without_api_key_falls_back_to_oauth() {
2303 let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2304 let get = |name: &str| env.get(name).cloned();
2305
2306 assert!(env_api_key(&get).is_none());
2307 assert!(env_github_access_token(&get).is_none());
2308 assert_eq!(
2309 env_base_url(&get).as_deref(),
2310 Some("https://copilot.example")
2311 );
2312 }
2313
2314 #[test]
2315 fn env_github_token_is_not_treated_as_copilot_api_key() {
2316 let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2317 let get = |name: &str| env.get(name).cloned();
2318
2319 assert!(env_api_key(&get).is_none());
2320 assert_eq!(
2321 env_github_access_token(&get).as_deref(),
2322 Some("bootstrap-token")
2323 );
2324 }
2325}