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::wasm_compat::{WasmCompatSend, WasmCompatSync};
41use async_stream::stream;
42use futures::StreamExt;
43use http::Request;
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46use std::borrow::Cow;
47use std::collections::HashMap;
48use std::fmt::Debug;
49use std::path::{Path, PathBuf};
50use tracing::info_span;
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 request = self.chat_request(completion_request)?;
827 let body = serde_json::to_vec(&request)?;
828 let auth = self.auth_context().await?;
829
830 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
831 let req = apply_headers(
832 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Http)?,
833 &headers,
834 )
835 .body(body)
836 .map_err(|err| CompletionError::HttpError(err.into()))?;
837
838 let span = if tracing::Span::current().is_disabled() {
839 info_span!(
840 target: "rig::completions",
841 "chat",
842 gen_ai.operation.name = "chat",
843 gen_ai.provider.name = "copilot",
844 gen_ai.request.model = self.model,
845 gen_ai.response.id = tracing::field::Empty,
846 gen_ai.response.model = tracing::field::Empty,
847 gen_ai.usage.output_tokens = tracing::field::Empty,
848 gen_ai.usage.input_tokens = tracing::field::Empty,
849 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
850 )
851 } else {
852 tracing::Span::current()
853 };
854
855 async move {
856 let response = self.client.send(req).await?;
857
858 let status = response.status();
859 if status.is_success() {
860 let body = http_client::text(response).await?;
861 match serde_json::from_str::<ChatApiResponse<ChatCompletionResponse>>(&body)? {
862 ChatApiResponse::Ok(response) => {
863 let core = completion::CompletionResponse::try_from(response.clone())?;
864 let span = tracing::Span::current();
865 span.record("gen_ai.response.id", response.id.as_str());
866 span.record("gen_ai.response.model", response.model.as_str());
867 if let Some(usage) = &response.usage {
868 span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
869 span.record(
870 "gen_ai.usage.output_tokens",
871 usage.total_tokens - usage.prompt_tokens,
872 );
873 span.record(
874 "gen_ai.usage.cache_read.input_tokens",
875 usage
876 .prompt_tokens_details
877 .as_ref()
878 .map(|details| details.cached_tokens)
879 .unwrap_or(0),
880 );
881 }
882
883 Ok(completion::CompletionResponse {
884 choice: core.choice,
885 usage: core.usage,
886 raw_response: CopilotCompletionResponse::Chat(Box::new(response)),
887 message_id: core.message_id,
888 })
889 }
890 ChatApiResponse::Err(err) => {
891 tracing::warn!(
892 message = %err.error_message(),
893 "provider returned an error response"
894 );
895 Err(CompletionError::from_http_response(status, body))
896 }
897 }
898 } else {
899 let body = http_client::text(response).await?;
900 Err(CompletionError::from_http_response(status, body))
901 }
902 }
903 .instrument(span)
904 .await
905 }
906
907 async fn completion_responses(
908 &self,
909 completion_request: completion::CompletionRequest,
910 ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
911 let initiator = request_initiator(&completion_request);
912 let has_vision = request_has_vision(&completion_request);
913 let request = self.responses_request(completion_request)?;
914 let auth = self.auth_context().await?;
915
916 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
917 let req = apply_headers(
918 post_with_auth_base(&self.client, &auth, "/responses", Transport::Http)?,
919 &headers,
920 )
921 .body(serde_json::to_vec(&request)?)
922 .map_err(|err| CompletionError::HttpError(err.into()))?;
923
924 let span = if tracing::Span::current().is_disabled() {
925 info_span!(
926 target: "rig::completions",
927 "chat",
928 gen_ai.operation.name = "chat",
929 gen_ai.provider.name = "copilot",
930 gen_ai.request.model = self.model,
931 gen_ai.response.id = tracing::field::Empty,
932 gen_ai.response.model = tracing::field::Empty,
933 gen_ai.usage.output_tokens = tracing::field::Empty,
934 gen_ai.usage.input_tokens = tracing::field::Empty,
935 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
936 )
937 } else {
938 tracing::Span::current()
939 };
940
941 async move {
942 let response = self.client.send(req).await?;
943 let status = response.status();
944 if status.is_success() {
945 let body = http_client::text(response).await?;
946 let response = serde_json::from_str::<responses_api::CompletionResponse>(&body)?;
947 let core = completion::CompletionResponse::try_from(response.clone())?;
948
949 let span = tracing::Span::current();
950 span.record("gen_ai.response.id", response.id.as_str());
951 span.record("gen_ai.response.model", response.model.as_str());
952 if let Some(usage) = &response.usage {
953 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
954 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
955 span.record(
956 "gen_ai.usage.cache_read.input_tokens",
957 usage
958 .input_tokens_details
959 .as_ref()
960 .map(|details| details.cached_tokens)
961 .unwrap_or(0),
962 );
963 }
964
965 Ok(completion::CompletionResponse {
966 choice: core.choice,
967 usage: core.usage,
968 raw_response: CopilotCompletionResponse::Responses(Box::new(response)),
969 message_id: core.message_id,
970 })
971 } else {
972 let body = http_client::text(response).await?;
973 Err(CompletionError::from_http_response(status, body))
974 }
975 }
976 .instrument(span)
977 .await
978 }
979
980 async fn stream_chat(
981 &self,
982 completion_request: completion::CompletionRequest,
983 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
984 let initiator = request_initiator(&completion_request);
985 let has_vision = request_has_vision(&completion_request);
986 let request = self.chat_request(completion_request)?;
987 let auth = self.auth_context().await?;
988 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
989 let mut request_json = serde_json::to_value(&request)?;
990 let request_object = request_json.as_object_mut().ok_or_else(|| {
991 CompletionError::ResponseError("copilot request body must be a JSON object".into())
992 })?;
993 request_object.insert("stream".to_owned(), json!(true));
994 request_object.insert(
995 "stream_options".to_owned(),
996 json!({ "include_usage": true }),
997 );
998
999 let req = apply_headers(
1000 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Sse)?,
1001 &headers,
1002 )
1003 .body(serde_json::to_vec(&request_json)?)
1004 .map_err(|err| CompletionError::HttpError(err.into()))?;
1005
1006 let span = if tracing::Span::current().is_disabled() {
1007 info_span!(
1008 target: "rig::completions",
1009 "chat_streaming",
1010 gen_ai.operation.name = "chat_streaming",
1011 gen_ai.provider.name = "copilot",
1012 gen_ai.request.model = self.model,
1013 gen_ai.response.id = tracing::field::Empty,
1014 gen_ai.response.model = tracing::field::Empty,
1015 gen_ai.usage.output_tokens = tracing::field::Empty,
1016 gen_ai.usage.input_tokens = tracing::field::Empty,
1017 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1018 )
1019 } else {
1020 tracing::Span::current()
1021 };
1022
1023 tracing::Instrument::instrument(
1024 send_copilot_chat_streaming_request(self.client.clone(), req),
1025 span,
1026 )
1027 .await
1028 }
1029
1030 async fn stream_responses(
1031 &self,
1032 completion_request: completion::CompletionRequest,
1033 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
1034 let initiator = request_initiator(&completion_request);
1035 let has_vision = request_has_vision(&completion_request);
1036 let mut request = self.responses_request(completion_request)?;
1037 request.stream = Some(true);
1038 let auth = self.auth_context().await?;
1039
1040 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
1041 let req = apply_headers(
1042 post_with_auth_base(&self.client, &auth, "/responses", Transport::Sse)?,
1043 &headers,
1044 )
1045 .body(serde_json::to_vec(&request)?)
1046 .map_err(|err| CompletionError::HttpError(err.into()))?;
1047
1048 let span = if tracing::Span::current().is_disabled() {
1049 info_span!(
1050 target: "rig::completions",
1051 "chat_streaming",
1052 gen_ai.operation.name = "chat_streaming",
1053 gen_ai.provider.name = "copilot",
1054 gen_ai.request.model = self.model,
1055 gen_ai.response.id = tracing::field::Empty,
1056 gen_ai.response.model = tracing::field::Empty,
1057 gen_ai.usage.output_tokens = tracing::field::Empty,
1058 gen_ai.usage.input_tokens = tracing::field::Empty,
1059 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1060 )
1061 } else {
1062 tracing::Span::current()
1063 };
1064
1065 let client = self.client.clone();
1066 let mut event_source = crate::http_client::sse::GenericEventSource::new(client, req);
1067
1068 let stream = tracing_futures::Instrument::instrument(
1069 stream! {
1070 let mut final_usage = responses_api::ResponsesUsage::new();
1071 let mut tool_calls: Vec<streaming::RawStreamingChoice<CopilotStreamingResponse>> = Vec::new();
1072 let mut tool_call_internal_ids: HashMap<String, String> = HashMap::new();
1073 let span = tracing::Span::current();
1074
1075 let mut terminated_with_error = false;
1076
1077 while let Some(event_result) = event_source.next().await {
1078 match event_result {
1079 Ok(crate::http_client::sse::Event::Open) => continue,
1080 Ok(crate::http_client::sse::Event::Message(evt)) => {
1081 if evt.data.trim().is_empty() {
1082 continue;
1083 }
1084
1085 let Ok(data) = serde_json::from_str::<responses_api::streaming::StreamingCompletionChunk>(&evt.data) else {
1086 continue;
1087 };
1088
1089 if let responses_api::streaming::StreamingCompletionChunk::Delta(chunk) = &data {
1090 use responses_api::streaming::{ItemChunkKind, StreamingItemDoneOutput};
1091
1092 match &chunk.data {
1093 ItemChunkKind::OutputItemAdded(message) => {
1094 if let StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } = message {
1095 let internal_call_id = tool_call_internal_ids
1096 .entry(func.id.clone())
1097 .or_insert_with(crate::id::generate)
1098 .clone();
1099 yield Ok(RawStreamingChoice::ToolCallDelta {
1100 id: func.id.clone(),
1101 internal_call_id,
1102 content: streaming::ToolCallDeltaContent::Name(func.name.clone()),
1103 });
1104 }
1105 }
1106 ItemChunkKind::OutputItemDone(message) => match message {
1107 StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } => {
1108 let internal_id = tool_call_internal_ids
1109 .entry(func.id.clone())
1110 .or_insert_with(crate::id::generate)
1111 .clone();
1112 let raw_tool_call = streaming::RawStreamingToolCall::new(
1113 func.id.clone(),
1114 func.name.clone(),
1115 func.arguments.clone(),
1116 )
1117 .with_internal_call_id(internal_id)
1118 .with_call_id(func.call_id.clone());
1119 tool_calls.push(RawStreamingChoice::ToolCall(raw_tool_call));
1120 }
1121 StreamingItemDoneOutput { item: responses_api::Output::Reasoning { summary, id, content, encrypted_content, .. }, .. } => {
1122 for reasoning_choice in responses_api::streaming::reasoning_choices_from_done_item(
1123 id,
1124 summary,
1125 content,
1126 encrypted_content.as_deref(),
1127 ) {
1128 match reasoning_choice {
1129 RawStreamingChoice::Reasoning { id, content } => {
1130 yield Ok(RawStreamingChoice::Reasoning { id, content });
1131 }
1132 RawStreamingChoice::ReasoningDelta { id, reasoning } => {
1133 yield Ok(RawStreamingChoice::ReasoningDelta { id, reasoning });
1134 }
1135 _ => {}
1136 }
1137 }
1138 }
1139 StreamingItemDoneOutput { item: responses_api::Output::Message(msg), .. } => {
1140 yield Ok(RawStreamingChoice::MessageId(msg.id.clone()));
1141 }
1142 StreamingItemDoneOutput { item: responses_api::Output::Unknown(value), .. } => {
1144 yield Ok(RawStreamingChoice::Unknown(value.clone()));
1145 }
1146 },
1147 ItemChunkKind::OutputTextDelta(delta) => {
1148 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1149 }
1150 ItemChunkKind::ReasoningSummaryTextDelta(delta) => {
1151 yield Ok(RawStreamingChoice::ReasoningDelta { id: None, reasoning: delta.delta.clone() })
1152 }
1153 ItemChunkKind::RefusalDelta(delta) => {
1154 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1155 }
1156 ItemChunkKind::FunctionCallArgsDelta(delta) => {
1157 if let Some(item_id) = chunk.item_id.as_ref() {
1158 let internal_call_id = tool_call_internal_ids
1159 .entry(item_id.clone())
1160 .or_insert_with(crate::id::generate)
1161 .clone();
1162 yield Ok(RawStreamingChoice::ToolCallDelta {
1163 id: item_id.clone(),
1164 internal_call_id,
1165 content: streaming::ToolCallDeltaContent::Delta(delta.delta.clone())
1166 })
1167 }
1168 }
1169 _ => continue,
1170 }
1171 }
1172
1173 if let responses_api::streaming::StreamingCompletionChunk::Response(chunk) = data {
1174 let responses_api::streaming::ResponseChunk { kind, response, .. } = *chunk;
1175 match kind {
1176 responses_api::streaming::ResponseChunkKind::ResponseCompleted => {
1177 span.record("gen_ai.response.id", response.id.as_str());
1178 span.record("gen_ai.response.model", response.model.as_str());
1179 if let Some(usage) = response.usage {
1180 final_usage = usage;
1181 }
1182 }
1183 responses_api::streaming::ResponseChunkKind::ResponseFailed
1184 | responses_api::streaming::ResponseChunkKind::ResponseIncomplete => {
1185 terminated_with_error = true;
1186 match response.error.as_ref() {
1195 Some(_) => yield Err(
1196 crate::provider_response::completion_error_from_body(
1197 evt.data.clone(),
1198 ),
1199 ),
1200 None => yield Err(CompletionError::ProviderError(
1201 "Copilot response stream failed".into(),
1202 )),
1203 }
1204 break;
1205 }
1206 _ => continue,
1207 }
1208 }
1209 }
1210 Err(crate::http_client::Error::StreamEnded) => {
1211 break;
1212 }
1213 Err(error) => {
1214 terminated_with_error = true;
1215 yield Err(CompletionError::from_stream_transport(error));
1216 break;
1217 }
1218 }
1219 }
1220
1221 event_source.close();
1222
1223 if terminated_with_error {
1224 return;
1225 }
1226
1227 for tool_call in &tool_calls {
1228 yield Ok(tool_call.to_owned())
1229 }
1230
1231 span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1232 span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1233 span.record(
1234 "gen_ai.usage.cache_read.input_tokens",
1235 final_usage
1236 .input_tokens_details
1237 .as_ref()
1238 .map(|details| details.cached_tokens)
1239 .unwrap_or(0),
1240 );
1241
1242 yield Ok(RawStreamingChoice::FinalResponse(
1243 CopilotStreamingResponse::Responses(
1244 responses_api::streaming::StreamingCompletionResponse { usage: final_usage }
1245 )
1246 ));
1247 },
1248 span,
1249 );
1250
1251 Ok(StreamingCompletionResponse::stream(Box::pin(stream)))
1252 }
1253}
1254
1255impl<H> completion::CompletionModel for CompletionModel<H>
1256where
1257 Client<H>: HttpClientExt + Clone + Debug + 'static,
1258 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1259{
1260 type Response = CopilotCompletionResponse;
1261 type StreamingResponse = CopilotStreamingResponse;
1262 type Client = Client<H>;
1263
1264 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1265 Self::new(client.clone(), model)
1266 }
1267
1268 async fn completion(
1269 &self,
1270 completion_request: completion::CompletionRequest,
1271 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1272 match self.route() {
1273 CompletionRoute::ChatCompletions => self.completion_chat(completion_request).await,
1274 CompletionRoute::Responses => self.completion_responses(completion_request).await,
1275 }
1276 }
1277
1278 async fn stream(
1279 &self,
1280 completion_request: completion::CompletionRequest,
1281 ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
1282 match self.route() {
1283 CompletionRoute::ChatCompletions => self.stream_chat(completion_request).await,
1284 CompletionRoute::Responses => self.stream_responses(completion_request).await,
1285 }
1286 }
1287}
1288
1289#[derive(Clone)]
1290pub struct EmbeddingModel<H = reqwest::Client> {
1291 client: Client<H>,
1292 pub model: String,
1293 pub encoding_format: Option<openai::EncodingFormat>,
1294 pub user: Option<String>,
1295 ndims: usize,
1296}
1297
1298#[derive(Deserialize)]
1299struct CopilotEmbeddingResponse {
1300 data: Vec<CopilotEmbeddingData>,
1301}
1302
1303#[derive(Deserialize)]
1304struct CopilotEmbeddingData {
1305 embedding: Vec<serde_json::Number>,
1306}
1307
1308impl<H> EmbeddingModel<H>
1309where
1310 Client<H>: HttpClientExt + Clone + Debug + 'static,
1311 H: Clone + Default + Debug + 'static,
1312{
1313 pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1314 Self {
1315 client,
1316 model: model.into(),
1317 encoding_format: None,
1318 user: None,
1319 ndims,
1320 }
1321 }
1322}
1323
1324impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1325where
1326 Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1327 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1328{
1329 const MAX_DOCUMENTS: usize = 1024;
1330 type Client = Client<H>;
1331
1332 fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1333 let model = model.into();
1334 let dims = ndims.unwrap_or(match model.as_str() {
1335 TEXT_EMBEDDING_3_LARGE => 3072,
1336 TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1337 _ => 0,
1338 });
1339 Self::new(client.clone(), model, dims)
1340 }
1341
1342 fn ndims(&self) -> usize {
1343 self.ndims
1344 }
1345
1346 async fn embed_texts(
1347 &self,
1348 documents: impl IntoIterator<Item = String>,
1349 ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1350 let documents = documents.into_iter().collect::<Vec<_>>();
1351 let auth = self
1352 .client
1353 .ext()
1354 .auth
1355 .auth_context()
1356 .await
1357 .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1358
1359 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1360 let mut body = json!({
1361 "model": self.model,
1362 "input": documents,
1363 });
1364
1365 let body_object = body.as_object_mut().ok_or_else(|| {
1366 EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1367 })?;
1368
1369 if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1370 body_object.insert("dimensions".to_owned(), json!(self.ndims));
1371 }
1372 if let Some(encoding_format) = &self.encoding_format {
1373 body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1374 }
1375 if let Some(user) = &self.user {
1376 body_object.insert("user".to_owned(), json!(user));
1377 }
1378
1379 let req = apply_headers(
1380 post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1381 &headers,
1382 )
1383 .body(serde_json::to_vec(&body)?)
1384 .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1385
1386 let response = self.client.send(req).await?;
1387 let status = response.status();
1388 if status.is_success() {
1389 let body: Vec<u8> = response.into_body().await?;
1390 #[derive(Deserialize)]
1391 struct NestedApiError {
1392 error: NestedApiErrorMessage,
1393 }
1394
1395 #[derive(Deserialize)]
1396 struct NestedApiErrorMessage {
1397 message: String,
1398 }
1399
1400 let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1401 Ok(parsed) => parsed,
1402 Err(parse_error) => {
1403 if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1404 tracing::warn!(message = %err.error.message, "provider returned an error response");
1405 return Err(EmbeddingError::from_http_response(
1406 status,
1407 String::from_utf8_lossy(&body).into_owned(),
1408 ));
1409 }
1410
1411 let preview = String::from_utf8_lossy(&body);
1412 let preview = if preview.len() > 512 {
1413 format!("{}...", &preview[..512])
1414 } else {
1415 preview.into_owned()
1416 };
1417
1418 return Err(EmbeddingError::ProviderError(format!(
1419 "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1420 )));
1421 }
1422 };
1423
1424 Ok(body
1425 .data
1426 .into_iter()
1427 .zip(documents.into_iter())
1428 .map(|(embedding, document)| embeddings::Embedding {
1429 document,
1430 vec: embedding
1431 .embedding
1432 .into_iter()
1433 .filter_map(|n| n.as_f64())
1434 .collect(),
1435 })
1436 .collect())
1437 } else {
1438 let text = http_client::text(response).await?;
1439 Err(EmbeddingError::from_http_response(status, text))
1440 }
1441 }
1442}
1443
1444const MODEL_LISTING_PATH: &str = "/models";
1445const MODEL_LISTING_PROVIDER: &str = "Copilot";
1446
1447#[derive(Debug, Deserialize)]
1448struct ListModelsResponse {
1449 data: Vec<ListModelEntry>,
1450}
1451
1452#[derive(Debug, Deserialize)]
1453struct ListModelEntry {
1454 id: String,
1455 #[serde(default)]
1456 name: Option<String>,
1457 #[serde(default)]
1458 vendor: Option<String>,
1459 #[serde(default)]
1460 capabilities: Option<ListModelEntryCapabilities>,
1461}
1462
1463#[derive(Debug, Deserialize)]
1464struct ListModelEntryCapabilities {
1465 #[serde(default, rename = "type")]
1466 r#type: Option<String>,
1467}
1468
1469impl From<ListModelEntry> for Model {
1470 fn from(value: ListModelEntry) -> Self {
1471 let mut model = Model::from_id(value.id);
1472 model.name = value.name;
1473 model.owned_by = value.vendor;
1474 if let Some(caps) = value.capabilities {
1475 model.r#type = caps.r#type;
1476 }
1477 model
1478 }
1479}
1480
1481#[derive(Clone)]
1483pub struct CopilotModelLister<H = reqwest::Client> {
1484 client: Client<H>,
1485}
1486
1487impl<H> ModelLister<H> for CopilotModelLister<H>
1488where
1489 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1490{
1491 type Client = Client<H>;
1492
1493 fn new(client: Self::Client) -> Self {
1494 Self { client }
1495 }
1496
1497 async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1498 let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1499 ModelListingError::AuthError {
1500 message: err.to_string(),
1501 }
1502 })?;
1503
1504 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1505 let req = apply_headers(
1506 get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1507 &headers,
1508 )
1509 .body(http_client::NoBody)?;
1510
1511 let response = self.client.send::<_, Vec<u8>>(req).await?;
1512
1513 if !response.status().is_success() {
1514 let status_code = response.status().as_u16();
1515 let body = response.into_body().await?;
1516 return Err(ModelListingError::api_error_with_context(
1517 MODEL_LISTING_PROVIDER,
1518 MODEL_LISTING_PATH,
1519 status_code,
1520 &body,
1521 ));
1522 }
1523
1524 let body = response.into_body().await?;
1525 let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
1526 ModelListingError::parse_error_with_context(
1527 MODEL_LISTING_PROVIDER,
1528 MODEL_LISTING_PATH,
1529 &error,
1530 &body,
1531 )
1532 })?;
1533 let models = api_resp.data.into_iter().map(Model::from).collect();
1534
1535 Ok(ModelList::new(models))
1536 }
1537}
1538
1539#[derive(Deserialize, Debug)]
1540struct ChatStreamingFunction {
1541 name: Option<String>,
1542 arguments: Option<String>,
1543}
1544
1545#[derive(Deserialize, Debug)]
1546struct ChatStreamingToolCall {
1547 index: usize,
1548 id: Option<String>,
1549 function: ChatStreamingFunction,
1550}
1551
1552impl From<&ChatStreamingToolCall> for CompatibleToolCallChunk {
1553 fn from(value: &ChatStreamingToolCall) -> Self {
1554 Self {
1555 index: value.index,
1556 id: value.id.clone(),
1557 name: value.function.name.clone(),
1558 arguments: value.function.arguments.clone(),
1559 }
1560 }
1561}
1562
1563#[derive(Deserialize, Debug, Default)]
1564struct ChatStreamingDelta {
1565 #[serde(default)]
1566 content: Option<String>,
1567 #[serde(default)]
1568 reasoning_content: Option<String>,
1569 #[serde(default, deserialize_with = "crate::json_utils::null_or_vec")]
1570 tool_calls: Vec<ChatStreamingToolCall>,
1571}
1572
1573#[derive(Deserialize, Debug, PartialEq)]
1574#[serde(rename_all = "snake_case")]
1575enum ChatFinishReason {
1576 ToolCalls,
1577 Stop,
1578 ContentFilter,
1579 Length,
1580 #[serde(untagged)]
1581 Other(String),
1582}
1583
1584#[derive(Deserialize, Debug)]
1585struct ChatStreamingChoice {
1586 delta: ChatStreamingDelta,
1587 finish_reason: Option<ChatFinishReason>,
1588}
1589
1590#[derive(Deserialize, Debug)]
1591struct ChatStreamingChunk {
1592 id: Option<String>,
1593 model: Option<String>,
1594 choices: Vec<ChatStreamingChoice>,
1595 usage: Option<openai::completion::Usage>,
1596}
1597
1598#[derive(Clone, Copy)]
1599struct CopilotChatCompatibleProfile;
1600
1601impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
1602 type Usage = openai::completion::Usage;
1603 type Detail = ();
1604 type FinalResponse = CopilotStreamingResponse;
1605
1606 fn normalize_chunk(
1607 &self,
1608 data: &str,
1609 ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
1610 let data = match serde_json::from_str::<ChatStreamingChunk>(data) {
1611 Ok(data) => data,
1612 Err(error) => {
1613 tracing::debug!(?error, "Couldn't parse Copilot chat SSE payload");
1614 return Ok(None);
1615 }
1616 };
1617
1618 Ok(Some(
1619 openai_chat_completions_compatible::normalize_first_choice_chunk(
1620 data.id,
1621 data.model,
1622 data.usage,
1623 &data.choices,
1624 |choice| CompatibleChoiceData {
1625 finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
1626 CompatibleFinishReason::ToolCalls
1627 } else {
1628 CompatibleFinishReason::Other
1629 },
1630 text: choice.delta.content.clone(),
1631 reasoning: choice.delta.reasoning_content.clone(),
1632 tool_calls: openai_chat_completions_compatible::tool_call_chunks(
1633 &choice.delta.tool_calls,
1634 ),
1635 details: Vec::new(),
1636 },
1637 ),
1638 ))
1639 }
1640
1641 fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
1642 CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
1643 usage,
1644 })
1645 }
1646
1647 fn uses_distinct_tool_call_eviction(&self) -> bool {
1648 true
1649 }
1650}
1651
1652async fn send_copilot_chat_streaming_request<T>(
1653 http_client: T,
1654 req: Request<Vec<u8>>,
1655) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError>
1656where
1657 T: HttpClientExt + Clone + 'static,
1658{
1659 openai_chat_completions_compatible::send_compatible_streaming_request(
1660 http_client,
1661 req,
1662 CopilotChatCompatibleProfile,
1663 )
1664 .await
1665}
1666
1667fn default_token_dir() -> Option<PathBuf> {
1668 config_dir().map(|dir| dir.join("github_copilot"))
1669}
1670
1671fn config_dir() -> Option<PathBuf> {
1672 #[cfg(target_os = "windows")]
1673 {
1674 std::env::var_os("APPDATA").map(PathBuf::from)
1675 }
1676
1677 #[cfg(not(target_os = "windows"))]
1678 {
1679 std::env::var_os("XDG_CONFIG_HOME")
1680 .map(PathBuf::from)
1681 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1682 }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687 use super::{
1688 ChatApiErrorResponse, ChatCompletionResponse, Client, CompletionRoute, CopilotIntent,
1689 TEXT_EMBEDDING_3_SMALL, base_url_from_token, default_headers, env_api_key, env_base_url,
1690 env_github_access_token, route_for_model,
1691 };
1692 use crate::client::CompletionClient;
1693 use crate::completion::CompletionModel;
1694 use crate::http_client;
1695 use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1696 sse_bytes_from_data_lines, sse_bytes_from_json_events,
1697 };
1698 use crate::streaming::StreamedAssistantContent;
1699 use crate::test_utils::MockStreamingClient;
1700 use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1701 use futures::StreamExt;
1702 use std::collections::HashMap;
1703
1704 fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1705 entries
1706 .iter()
1707 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1708 .collect()
1709 }
1710
1711 fn minimal_chat_response() -> &'static str {
1712 r#"{
1713 "id": "chatcmpl-123",
1714 "model": "gpt-4o",
1715 "choices": [{
1716 "index": 0,
1717 "message": {
1718 "role": "assistant",
1719 "content": "hello"
1720 },
1721 "finish_reason": "stop"
1722 }],
1723 "usage": {
1724 "prompt_tokens": 4,
1725 "total_tokens": 7
1726 }
1727 }"#
1728 }
1729
1730 fn minimal_responses_response() -> &'static str {
1731 r#"{
1732 "id": "resp_123",
1733 "object": "response",
1734 "created_at": 1700000000,
1735 "status": "completed",
1736 "error": null,
1737 "incomplete_details": null,
1738 "instructions": null,
1739 "max_output_tokens": null,
1740 "model": "gpt-5.3-codex",
1741 "usage": {
1742 "input_tokens": 4,
1743 "input_tokens_details": {
1744 "cached_tokens": 0
1745 },
1746 "output_tokens": 3,
1747 "output_tokens_details": {
1748 "reasoning_tokens": 0
1749 },
1750 "total_tokens": 7
1751 },
1752 "output": [{
1753 "type": "message",
1754 "id": "msg_123",
1755 "role": "assistant",
1756 "status": "completed",
1757 "content": [{
1758 "type": "output_text",
1759 "text": "hello"
1760 }]
1761 }],
1762 "tools": []
1763 }"#
1764 }
1765
1766 fn minimal_embeddings_response() -> &'static str {
1767 r#"{
1768 "data": [
1769 {
1770 "embedding": [0.1, 0.2, 0.3]
1771 },
1772 {
1773 "embedding": [0.4, 0.5, 0.6]
1774 }
1775 ]
1776 }"#
1777 }
1778
1779 #[test]
1780 fn deserialize_standard_openai_response() {
1781 let json = r#"{
1782 "id": "chatcmpl-abc123",
1783 "object": "chat.completion",
1784 "created": 1700000000,
1785 "model": "gpt-4o",
1786 "choices": [{
1787 "index": 0,
1788 "message": {
1789 "role": "assistant",
1790 "content": "Hello!"
1791 },
1792 "finish_reason": "stop"
1793 }],
1794 "usage": {
1795 "prompt_tokens": 10,
1796 "completion_tokens": 5,
1797 "total_tokens": 15
1798 }
1799 }"#;
1800
1801 let response: ChatCompletionResponse =
1802 serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1803 assert_eq!(response.id, "chatcmpl-abc123");
1804 assert_eq!(response.object.as_deref(), Some("chat.completion"));
1805 assert_eq!(response.created, Some(1700000000));
1806 assert_eq!(response.model, "gpt-4o");
1807 assert_eq!(response.choices.len(), 1);
1808 assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
1809 }
1810
1811 #[test]
1812 fn deserialize_copilot_response_without_object_and_created() {
1813 let response: ChatCompletionResponse = serde_json::from_str(minimal_chat_response())
1814 .expect("Copilot response should deserialize");
1815
1816 assert_eq!(response.id, "chatcmpl-123");
1817 assert_eq!(response.object, None);
1818 assert_eq!(response.created, None);
1819 assert_eq!(response.model, "gpt-4o");
1820 assert_eq!(response.choices.len(), 1);
1821 }
1822
1823 #[test]
1824 fn deserialize_copilot_response_without_finish_reason() {
1825 let json = r#"{
1826 "id": "chatcmpl-claude-001",
1827 "model": "claude-3.5-sonnet",
1828 "choices": [{
1829 "message": {
1830 "role": "assistant",
1831 "content": "Here is my analysis."
1832 }
1833 }],
1834 "usage": {
1835 "prompt_tokens": 50,
1836 "total_tokens": 80
1837 }
1838 }"#;
1839
1840 let response: ChatCompletionResponse =
1841 serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1842
1843 assert_eq!(response.model, "claude-3.5-sonnet");
1844 assert_eq!(response.choices[0].finish_reason, None);
1845 assert_eq!(response.choices[0].index, 0);
1846 }
1847
1848 #[test]
1849 fn error_response_with_message_field() {
1850 let json = r#"{"message": "rate limit exceeded"}"#;
1851 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1852
1853 assert_eq!(err.error_message(), "rate limit exceeded");
1854 }
1855
1856 #[test]
1857 fn error_response_with_error_field() {
1858 let json = r#"{"error": "model not found"}"#;
1859 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1860
1861 assert_eq!(err.error_message(), "model not found");
1862 }
1863
1864 #[test]
1865 fn routes_codex_models_to_responses() {
1866 assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1867 assert_eq!(
1868 route_for_model("gpt-5.1-CODEX-mini"),
1869 CompletionRoute::Responses
1870 );
1871 assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1872 assert_eq!(
1873 route_for_model("claude-sonnet-4.5"),
1874 CompletionRoute::ChatCompletions
1875 );
1876 }
1877
1878 #[test]
1879 fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1880 let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1881 assert_eq!(
1882 panel_headers
1883 .iter()
1884 .find(|(name, _)| *name == "openai-intent")
1885 .map(|(_, value)| value.as_str()),
1886 Some("conversation-panel")
1887 );
1888
1889 let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1890 assert_eq!(
1891 edits_headers
1892 .iter()
1893 .find(|(name, _)| *name == "openai-intent")
1894 .map(|(_, value)| value.as_str()),
1895 Some("conversation-edits")
1896 );
1897 }
1898
1899 #[test]
1900 fn copilot_completion_model_intent_builders_update_intent() {
1901 let client = Client::builder()
1902 .api_key("copilot-token")
1903 .build()
1904 .expect("build client");
1905
1906 let default_model = client.completion_model("gpt-4o");
1907 assert_eq!(default_model.intent.as_header(), "conversation-panel");
1908
1909 let edits_model = client
1910 .completion_model("gpt-4o")
1911 .with_intent(CopilotIntent::Edits);
1912 assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1913
1914 let panel_model = client
1915 .completion_model("gpt-4o")
1916 .with_edits_intent()
1917 .with_panel_intent();
1918 assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1919 }
1920
1921 #[test]
1922 fn base_url_from_token_derives_api_endpoint() {
1923 assert_eq!(
1924 base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1925 .as_deref(),
1926 Some("https://api.individual.githubcopilot.com")
1927 );
1928 assert_eq!(
1929 base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1930 .as_deref(),
1931 Some("https://api.individual.githubcopilot.com")
1932 );
1933 assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1934 }
1935
1936 #[test]
1937 fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1938 assert_eq!(
1939 base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1940 None
1941 );
1942 assert_eq!(
1943 base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1944 None
1945 );
1946 assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1947 assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1948 assert_eq!(
1949 base_url_from_token(
1950 "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1951 ),
1952 None
1953 );
1954 }
1955
1956 #[tokio::test]
1957 async fn api_key_with_proxy_endpoint_overrides_base_url() {
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 .http_client(http_client.clone())
1962 .build()
1963 .expect("build client");
1964 let model = client.completion_model("gpt-4o");
1965 let request = model.completion_request("hello").build();
1966
1967 let _response = model.completion(request).await.expect("chat completion");
1968
1969 let requests = http_client.requests();
1970 assert_eq!(requests.len(), 1);
1971 assert!(
1972 requests[0]
1973 .uri
1974 .starts_with("https://api.individual.githubcopilot.com"),
1975 "expected proxy-derived base URL, got {}",
1976 requests[0].uri
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1982 let http_client = RecordingHttpClient::new(minimal_chat_response());
1983 let client = Client::builder()
1984 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1985 .base_url("https://custom.example.com")
1986 .http_client(http_client.clone())
1987 .build()
1988 .expect("build client");
1989 let model = client.completion_model("gpt-4o");
1990 let request = model.completion_request("hello").build();
1991
1992 let _response = model.completion(request).await.expect("chat completion");
1993
1994 let requests = http_client.requests();
1995 assert_eq!(requests.len(), 1);
1996 assert!(
1997 requests[0].uri.starts_with("https://custom.example.com"),
1998 "expected explicit base URL, got {}",
1999 requests[0].uri
2000 );
2001 }
2002
2003 #[tokio::test]
2004 async fn completion_model_edits_intent_sets_request_header() {
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").with_edits_intent();
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_eq!(
2019 requests[0]
2020 .headers
2021 .get("openai-intent")
2022 .and_then(|value| value.to_str().ok()),
2023 Some("conversation-edits")
2024 );
2025 }
2026
2027 #[tokio::test]
2028 async fn completion_model_routes_chat_requests_to_chat_completions() {
2029 let http_client = RecordingHttpClient::new(minimal_chat_response());
2030 let client = Client::builder()
2031 .api_key("copilot-token")
2032 .http_client(http_client.clone())
2033 .build()
2034 .expect("build client");
2035 let model = client.completion_model("gpt-4o");
2036 let request = model.completion_request("hello").build();
2037
2038 let _response = model.completion(request).await.expect("chat completion");
2039
2040 let requests = http_client.requests();
2041 assert_eq!(requests.len(), 1);
2042 assert!(requests[0].uri.ends_with("/chat/completions"));
2043 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
2044 }
2045
2046 #[tokio::test]
2047 async fn completion_model_routes_codex_requests_to_responses() {
2048 let http_client = RecordingHttpClient::new(minimal_responses_response());
2049 let client = Client::builder()
2050 .api_key("copilot-token")
2051 .http_client(http_client.clone())
2052 .build()
2053 .expect("build client");
2054 let model = client.completion_model("gpt-5.3-codex");
2055 let request = model.completion_request("hello").build();
2056
2057 let _response = model
2058 .completion(request)
2059 .await
2060 .expect("responses completion");
2061
2062 let requests = http_client.requests();
2063 assert_eq!(requests.len(), 1);
2064 assert!(requests[0].uri.ends_with("/responses"));
2065 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
2066 }
2067
2068 #[tokio::test]
2069 async fn embeddings_accept_minimal_copilot_response_shape() {
2070 use crate::client::EmbeddingsClient;
2071 use crate::embeddings::EmbeddingModel as _;
2072
2073 let http_client = RecordingHttpClient::new(minimal_embeddings_response());
2074 let client = Client::builder()
2075 .api_key("copilot-token")
2076 .http_client(http_client.clone())
2077 .build()
2078 .expect("build client");
2079 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
2080
2081 let embeddings = model
2082 .embed_texts(["one".to_string(), "two".to_string()])
2083 .await
2084 .expect("embeddings should deserialize");
2085
2086 assert_eq!(embeddings.len(), 2);
2087 assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
2088 assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
2089
2090 let requests = http_client.requests();
2091 assert_eq!(requests.len(), 1);
2092 assert!(requests[0].uri.ends_with("/embeddings"));
2093 assert!(
2094 String::from_utf8_lossy(&requests[0].body)
2095 .contains("\"model\":\"text-embedding-3-small\"")
2096 );
2097 }
2098
2099 #[tokio::test]
2100 async fn responses_stream_terminates_after_terminal_error() {
2101 let tool_call_done = serde_json::json!({
2102 "type": "response.output_item.done",
2103 "sequence_number": 1,
2104 "item": {
2105 "type": "function_call",
2106 "id": "fc_123",
2107 "arguments": "{}",
2108 "call_id": "call_123",
2109 "name": "example_tool",
2110 "status": "completed"
2111 }
2112 });
2113 let failed = serde_json::json!({
2114 "type": "response.failed",
2115 "sequence_number": 2,
2116 "response": {
2117 "id": "resp_123",
2118 "object": "response",
2119 "created_at": 1700000000,
2120 "status": "failed",
2121 "error": {
2122 "code": "server_error",
2123 "message": "Copilot response stream failed"
2124 },
2125 "incomplete_details": null,
2126 "instructions": null,
2127 "max_output_tokens": null,
2128 "model": "gpt-5.3-codex",
2129 "usage": null,
2130 "output": [],
2131 "tools": []
2132 }
2133 });
2134 let http_client = MockStreamingClient {
2135 sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2136 };
2137 let client = Client::builder()
2138 .api_key("copilot-token")
2139 .http_client(http_client)
2140 .build()
2141 .expect("build client");
2142 let model = client.completion_model("gpt-5.3-codex");
2143 let request = model.completion_request("hello").build();
2144 let mut stream = model.stream(request).await.expect("stream should start");
2145
2146 let err = match stream.next().await.expect("stream should yield an item") {
2147 Ok(_) => panic!("stream should surface a provider error"),
2148 Err(err) => err,
2149 };
2150 assert!(matches!(
2155 err,
2156 crate::completion::CompletionError::ProviderResponse(_)
2157 ));
2158 assert_eq!(err.provider_response_status(), None);
2159 let json = err
2160 .provider_response_json()
2161 .expect("preserved body should parse as JSON")
2162 .expect("preserved body should not be empty");
2163 let response_error = json
2164 .get("response")
2165 .and_then(|response| response.get("error"))
2166 .expect("preserved body should retain the provider error object");
2167 assert_eq!(
2168 response_error.get("code").and_then(|value| value.as_str()),
2169 Some("server_error")
2170 );
2171 assert_eq!(
2172 response_error
2173 .get("message")
2174 .and_then(|value| value.as_str()),
2175 Some("Copilot response stream failed")
2176 );
2177 assert!(
2178 stream.next().await.is_none(),
2179 "responses stream should terminate immediately after a terminal error"
2180 );
2181 }
2182
2183 #[tokio::test]
2184 async fn chat_stream_terminates_after_transport_error() {
2185 let chunks = vec![
2186 Ok(sse_bytes_from_data_lines([
2187 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2188 ])),
2189 Err(http_client::Error::InvalidStatusCode(
2190 http::StatusCode::BAD_GATEWAY,
2191 )),
2192 ];
2193
2194 let http_client = SequencedStreamingHttpClient::new(chunks);
2195 let client = Client::builder()
2196 .api_key("copilot-token")
2197 .http_client(http_client)
2198 .build()
2199 .expect("build client");
2200 let model = client.completion_model("gpt-4o");
2201 let request = model.completion_request("hello").build();
2202 let mut stream = model.stream(request).await.expect("stream should start");
2203
2204 let mut saw_error = false;
2205 while let Some(item) = stream.next().await {
2206 match item {
2207 Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {}
2208 Err(err) => {
2209 assert_eq!(
2210 err.to_string(),
2211 "HttpError: Invalid status code: 502 Bad Gateway"
2212 );
2213 assert_eq!(
2214 err.provider_response_status(),
2215 Some(http::StatusCode::BAD_GATEWAY)
2216 );
2217 assert_eq!(err.provider_response_body(), None);
2218 saw_error = true;
2219 break;
2220 }
2221 Ok(_) => panic!("unexpected non-error stream item before transport failure"),
2222 }
2223 }
2224
2225 assert!(saw_error, "stream should surface the transport error");
2226 assert!(
2227 stream.next().await.is_none(),
2228 "chat stream should terminate immediately after a transport error"
2229 );
2230 }
2231
2232 #[test]
2233 fn env_api_key_prefers_github_prefixed_vars() {
2234 let env = env_map(&[
2235 ("COPILOT_API_KEY", "copilot-key"),
2236 ("GITHUB_COPILOT_API_KEY", "github-key"),
2237 ("GITHUB_TOKEN", "bootstrap-token"),
2238 ]);
2239 let get = |name: &str| env.get(name).cloned();
2240
2241 assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2242 }
2243
2244 #[test]
2245 fn env_github_access_token_prefers_explicit_bootstrap_var() {
2246 let env = env_map(&[
2247 ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2248 ("GITHUB_TOKEN", "fallback-bootstrap"),
2249 ]);
2250 let get = |name: &str| env.get(name).cloned();
2251
2252 assert_eq!(
2253 env_github_access_token(&get).as_deref(),
2254 Some("explicit-bootstrap")
2255 );
2256 }
2257
2258 #[test]
2259 fn env_base_url_prefers_github_prefixed_vars() {
2260 let env = env_map(&[
2261 ("COPILOT_BASE_URL", "https://copilot.example"),
2262 ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2263 ]);
2264 let get = |name: &str| env.get(name).cloned();
2265
2266 assert_eq!(
2267 env_base_url(&get).as_deref(),
2268 Some("https://github.example")
2269 );
2270 }
2271
2272 #[test]
2273 fn env_without_api_key_falls_back_to_oauth() {
2274 let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2275 let get = |name: &str| env.get(name).cloned();
2276
2277 assert!(env_api_key(&get).is_none());
2278 assert!(env_github_access_token(&get).is_none());
2279 assert_eq!(
2280 env_base_url(&get).as_deref(),
2281 Some("https://copilot.example")
2282 );
2283 }
2284
2285 #[test]
2286 fn env_github_token_is_not_treated_as_copilot_api_key() {
2287 let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2288 let get = |name: &str| env.get(name).cloned();
2289
2290 assert!(env_api_key(&get).is_none());
2291 assert_eq!(
2292 env_github_access_token(&get).as_deref(),
2293 Some("bootstrap-token")
2294 );
2295 }
2296}