Skip to main content

rig_core/providers/copilot/
mod.rs

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